mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-14 05:28:57 +08:00
fix(toggle): improve error text accessibility
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
||||
import { Component, Element, Event, Host, Method, Prop, State, h, Build } 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';
|
||||
|
||||
184
core/src/components/toggle/test/validation/index.html
Normal file
184
core/src/components/toggle/test/validation/index.html
Normal file
@@ -0,0 +1,184 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Toggle - 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>Toggle - 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-toggle
|
||||
id="terms-toggle"
|
||||
helper-text="You must turn on to continue"
|
||||
error-text="Please turn on this toggle"
|
||||
required
|
||||
>Tap to turn on</ion-toggle
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Optional Field (No Validation)</h2>
|
||||
<ion-toggle id="optional-toggle" helper-text="You can skip this field">Optional Toggle</ion-toggle>
|
||||
</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 toggles = document.querySelectorAll('ion-toggle');
|
||||
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-toggle': (checked) => {
|
||||
return checked === true;
|
||||
},
|
||||
'optional-toggle': () => true, // Always valid
|
||||
};
|
||||
|
||||
function validateField(toggle) {
|
||||
const toggleId = toggle.id;
|
||||
const checked = toggle.checked;
|
||||
const isValid = validators[toggleId] ? validators[toggleId](checked) : true;
|
||||
|
||||
// Only show validation state if field has been touched
|
||||
if (touchedFields.has(toggleId)) {
|
||||
if (isValid) {
|
||||
toggle.classList.remove('ion-invalid');
|
||||
toggle.classList.add('ion-valid');
|
||||
} else {
|
||||
toggle.classList.remove('ion-valid');
|
||||
toggle.classList.add('ion-invalid');
|
||||
}
|
||||
toggle.classList.add('ion-touched');
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
let allValid = true;
|
||||
toggles.forEach((toggle) => {
|
||||
if (toggle.id !== 'optional-toggle') {
|
||||
const isValid = validateField(toggle);
|
||||
if (!isValid) {
|
||||
allValid = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
submitBtn.disabled = !allValid;
|
||||
return allValid;
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
toggles.forEach((toggle) => {
|
||||
// Mark as touched on blur
|
||||
toggle.addEventListener('ionBlur', (e) => {
|
||||
console.log('Blur event on:', toggle.id);
|
||||
touchedFields.add(toggle.id);
|
||||
validateField(toggle);
|
||||
validateForm();
|
||||
|
||||
const isInvalid = toggle.classList.contains('ion-invalid');
|
||||
if (isInvalid) {
|
||||
console.log('Field marked invalid:', toggle.label, toggle.errorText);
|
||||
}
|
||||
});
|
||||
|
||||
// Validate on change
|
||||
toggle.addEventListener('ionChange', (e) => {
|
||||
console.log('Change event on:', toggle.id);
|
||||
if (touchedFields.has(toggle.id)) {
|
||||
validateField(toggle);
|
||||
validateForm();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Reset button
|
||||
resetBtn.addEventListener('click', () => {
|
||||
toggles.forEach((toggle) => {
|
||||
toggle.checked = false;
|
||||
toggle.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>
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
||||
import { Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core';
|
||||
import { Build, Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core';
|
||||
import { checkInvalidState } from '@utils/forms';
|
||||
import { renderHiddenInput, inheritAriaAttributes } from '@utils/helpers';
|
||||
import type { Attributes } from '@utils/helpers';
|
||||
import { hapticSelection } from '@utils/native/haptic';
|
||||
@@ -44,11 +45,19 @@ export class Toggle implements ComponentInterface {
|
||||
private inheritedAttributes: Attributes = {};
|
||||
private toggleTrack?: HTMLElement;
|
||||
private didLoad = false;
|
||||
private validationObserver?: MutationObserver;
|
||||
|
||||
@Element() el!: HTMLIonToggleElement;
|
||||
|
||||
@State() activated = false;
|
||||
|
||||
/**
|
||||
* Track validation state for proper aria-live announcements.
|
||||
*/
|
||||
@State() isInvalid = false;
|
||||
|
||||
@State() private hintTextID?: string;
|
||||
|
||||
/**
|
||||
* The color to use from your application's color palette.
|
||||
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
|
||||
@@ -168,15 +177,56 @@ export class Toggle implements ComponentInterface {
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
const { didLoad, el } = this;
|
||||
|
||||
/**
|
||||
* If we have not yet rendered
|
||||
* ion-toggle, then toggleTrack is not defined.
|
||||
* But if we are moving ion-toggle via appendChild,
|
||||
* then toggleTrack will be defined.
|
||||
*/
|
||||
if (this.didLoad) {
|
||||
if (didLoad) {
|
||||
this.setupGesture();
|
||||
}
|
||||
|
||||
// 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(() => {
|
||||
this.hintTextID = this.getHintTextID();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.validationObserver.observe(el, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
}
|
||||
|
||||
// Always set initial state
|
||||
this.isInvalid = checkInvalidState(el);
|
||||
}
|
||||
|
||||
componentDidLoad() {
|
||||
@@ -207,6 +257,12 @@ export class Toggle implements ComponentInterface {
|
||||
this.gesture.destroy();
|
||||
this.gesture = undefined;
|
||||
}
|
||||
|
||||
// Clean up validation observer to prevent memory leaks.
|
||||
if (this.validationObserver) {
|
||||
this.validationObserver.disconnect();
|
||||
this.validationObserver = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
componentWillLoad() {
|
||||
@@ -336,9 +392,9 @@ export class Toggle 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;
|
||||
}
|
||||
|
||||
@@ -354,7 +410,7 @@ export class Toggle 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
|
||||
@@ -367,11 +423,11 @@ export class Toggle implements ComponentInterface {
|
||||
|
||||
return (
|
||||
<div class="toggle-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>
|
||||
);
|
||||
@@ -385,7 +441,6 @@ export class Toggle implements ComponentInterface {
|
||||
color,
|
||||
disabled,
|
||||
el,
|
||||
errorTextId,
|
||||
hasLabel,
|
||||
inheritedAttributes,
|
||||
inputId,
|
||||
@@ -405,12 +460,13 @@ export class Toggle implements ComponentInterface {
|
||||
<Host
|
||||
role="switch"
|
||||
aria-checked={`${checked}`}
|
||||
aria-describedby={this.getHintTextID()}
|
||||
aria-invalid={this.getHintTextID() === errorTextId}
|
||||
aria-describedby={this.hintTextID}
|
||||
aria-invalid={this.isInvalid ? 'true' : undefined}
|
||||
onClick={this.onClick}
|
||||
aria-labelledby={hasLabel ? 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}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
type FormElement = HTMLIonInputElement | HTMLIonTextareaElement | HTMLIonSelectElement | HTMLIonCheckboxElement;
|
||||
type FormElement =
|
||||
| HTMLIonInputElement
|
||||
| HTMLIonTextareaElement
|
||||
| HTMLIonSelectElement
|
||||
| HTMLIonCheckboxElement
|
||||
| HTMLIonToggleElement;
|
||||
|
||||
/**
|
||||
* Checks if the form element is in an invalid state based on
|
||||
|
||||
Reference in New Issue
Block a user