fix(toggle): use a native input to fix a11y issues with axe and screen readers (#22477)

fixes #22011
references #21552
This commit is contained in:
Brandy Carney
2020-11-12 15:29:05 -05:00
committed by GitHub
parent 96d6012071
commit 813611a61b
6 changed files with 88 additions and 72 deletions

View File

@ -10,6 +10,7 @@
* [Example Components](#example-components) * [Example Components](#example-components)
* [References](#references) * [References](#references)
- [Accessibility](#accessibility) - [Accessibility](#accessibility)
* [Checkbox](#checkbox)
- [Rendering Anchor or Button](#rendering-anchor-or-button) - [Rendering Anchor or Button](#rendering-anchor-or-button)
* [Example Components](#example-components-1) * [Example Components](#example-components-1)
* [Component Structure](#component-structure-1) * [Component Structure](#component-structure-1)
@ -370,6 +371,7 @@ ion-ripple-effect {
#### Example Components #### Example Components
- [ion-checkbox](https://github.com/ionic-team/ionic/tree/master/core/src/components/checkbox) - [ion-checkbox](https://github.com/ionic-team/ionic/tree/master/core/src/components/checkbox)
- [ion-toggle](https://github.com/ionic-team/ionic/tree/master/core/src/components/toggle)
#### VoiceOver #### VoiceOver

View File

@ -18,7 +18,7 @@
<ion-toolbar> <ion-toolbar>
<ion-title>Toggle - Basic</ion-title> <ion-title>Toggle - Basic</ion-title>
<ion-buttons slot="primary"> <ion-buttons slot="primary">
<ion-toggle></ion-toggle> <ion-toggle aria-label="Toggle"></ion-toggle>
</ion-buttons> </ion-buttons>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
@ -86,7 +86,7 @@
</p> </p>
<p> <p>
<ion-toggle id="standAloneChecked"></ion-toggle> <ion-toggle aria-label="Stand-alone toggle" id="standAloneChecked"></ion-toggle>
Stand-alone toggle: Stand-alone toggle:
<span id="standAloneCheckedSpan"></span> <span id="standAloneCheckedSpan"></span>
</p> </p>

View File

@ -25,53 +25,53 @@
<ion-content class="ion-padding-horizontal"> <ion-content class="ion-padding-horizontal">
<h1>Default</h1> <h1>Default</h1>
<ion-toggle></ion-toggle> <ion-toggle aria-label="Default"></ion-toggle>
<ion-toggle checked></ion-toggle> <ion-toggle aria-label="Default" checked></ion-toggle>
<ion-toggle color="danger"></ion-toggle> <ion-toggle aria-label="Default Danger" color="danger"></ion-toggle>
<ion-toggle color="danger" checked></ion-toggle> <ion-toggle aria-label="Default Danger" color="danger" checked></ion-toggle>
<ion-toggle color="tertiary" class="toggle-activated"></ion-toggle> <ion-toggle aria-label="Default Tertiary" color="tertiary" class="toggle-activated"></ion-toggle>
<ion-toggle color="tertiary" checked class="toggle-activated"></ion-toggle> <ion-toggle aria-label="Default Tertiary Activated" color="tertiary" checked class="toggle-activated"></ion-toggle>
<h1>Custom Widths</h1> <h1>Custom Widths</h1>
<ion-toggle color="secondary" class="width-small"></ion-toggle> <ion-toggle aria-label="Secondary Small Width" color="secondary" class="width-small"></ion-toggle>
<ion-toggle color="secondary" checked class="width-small"></ion-toggle> <ion-toggle aria-label="Secondary Small Width" color="secondary" checked class="width-small"></ion-toggle>
<ion-toggle color="secondary" class="width-large"></ion-toggle> <ion-toggle aria-label="Secondary Large Width" color="secondary" class="width-large"></ion-toggle>
<ion-toggle color="secondary" checked class="width-large"></ion-toggle> <ion-toggle aria-label="Secondary Large Width" color="secondary" checked class="width-large"></ion-toggle>
<ion-toggle color="tertiary" class="width-large toggle-activated"></ion-toggle> <ion-toggle aria-label="Tertiary Large Width Activated" color="tertiary" class="width-large toggle-activated"></ion-toggle>
<ion-toggle color="tertiary" checked class="width-large toggle-activated"></ion-toggle> <ion-toggle aria-label="Tertiary Large Width Activated" color="tertiary" checked class="width-large toggle-activated"></ion-toggle>
<h1>Custom Heights</h1> <h1>Custom Heights</h1>
<div style="display: flex; flex-flow: column; float: left;"> <div style="display: flex; flex-flow: column; float: left;">
<ion-toggle class="height-small"></ion-toggle> <ion-toggle aria-label="Small Height" class="height-small"></ion-toggle>
<ion-toggle checked class="height-small"></ion-toggle> <ion-toggle aria-label="Small Height" checked class="height-small"></ion-toggle>
</div> </div>
<ion-toggle class="height-large"></ion-toggle> <ion-toggle aria-label="Large Height" class="height-large"></ion-toggle>
<ion-toggle checked class="height-large"></ion-toggle> <ion-toggle aria-label="Large Height" checked class="height-large"></ion-toggle>
<ion-toggle class="handle-height-large"></ion-toggle> <ion-toggle aria-label="Large Height" class="handle-height-large"></ion-toggle>
<ion-toggle checked class="handle-height-large"></ion-toggle> <ion-toggle aria-label="Large Height" checked class="handle-height-large"></ion-toggle>
<ion-toggle checked class="handle-height-large toggle-activated"></ion-toggle> <ion-toggle aria-label="Large Height Activated" checked class="handle-height-large toggle-activated"></ion-toggle>
<h1>Dynamic Sizes</h1> <h1>Dynamic Sizes</h1>
<ion-toggle color="tertiary" class="dynamic-small width-small height-small"></ion-toggle> <ion-toggle aria-label="Tertiary Small Width Small Height" color="tertiary" class="dynamic-small width-small height-small"></ion-toggle>
<ion-toggle color="tertiary" checked class="dynamic-small width-small height-small"></ion-toggle> <ion-toggle aria-label="Tertiary Small Width Small Height" color="tertiary" checked class="dynamic-small width-small height-small"></ion-toggle>
<ion-toggle color="tertiary" class="dynamic-large width-large height-large"></ion-toggle> <ion-toggle aria-label="Tertiary Large Width Large Height" color="tertiary" class="dynamic-large width-large height-large"></ion-toggle>
<ion-toggle color="tertiary" checked class="dynamic-large width-large height-large"></ion-toggle> <ion-toggle aria-label="Tertiary Large Width Large Height" color="tertiary" checked class="dynamic-large width-large height-large"></ion-toggle>
<h1>Complex Custom Toggles</h1> <h1>Complex Custom Toggles</h1>
<ion-toggle mode="ios" class="all-custom"></ion-toggle> <ion-toggle aria-label="Custom" mode="ios" class="all-custom"></ion-toggle>
<ion-toggle mode="ios" checked class="all-custom"></ion-toggle> <ion-toggle aria-label="Custom" mode="ios" checked class="all-custom"></ion-toggle>
<ion-toggle class="custom-overflow"></ion-toggle> <ion-toggle aria-label="Custom Overflow" class="custom-overflow"></ion-toggle>
<ion-toggle checked class="custom-overflow"></ion-toggle> <ion-toggle aria-label="Custom Overflow" checked class="custom-overflow"></ion-toggle>
<ion-toggle mode="ios" color="dark" class="custom-spacing"></ion-toggle> <ion-toggle aria-label="Custom Spacing iOS" mode="ios" color="dark" class="custom-spacing"></ion-toggle>
<ion-toggle mode="ios" color="dark" checked class="custom-spacing"></ion-toggle> <ion-toggle aria-label="Custom Spacing iOS" mode="ios" color="dark" checked class="custom-spacing"></ion-toggle>
<ion-toggle mode="md" color="dark" class="custom-spacing"></ion-toggle> <ion-toggle aria-label="Custom Spacing MD" mode="md" color="dark" class="custom-spacing"></ion-toggle>
<ion-toggle mode="md" color="dark" checked class="custom-spacing"></ion-toggle> <ion-toggle aria-label="Custom Spacing MD" mode="md" color="dark" checked class="custom-spacing"></ion-toggle>
<ion-toggle mode="ios" class="icon-custom"></ion-toggle> <ion-toggle aria-label="Custom Icon iOS" mode="ios" class="icon-custom"></ion-toggle>
<ion-toggle mode="ios" checked class="icon-custom"></ion-toggle> <ion-toggle aria-label="Custom Icon iOS" mode="ios" checked class="icon-custom"></ion-toggle>
</ion-content> </ion-content>
</ion-app> </ion-app>

View File

@ -13,23 +13,23 @@
<body> <body>
<!-- Default --> <!-- Default -->
<ion-toggle></ion-toggle> <ion-toggle aria-label="Default Toggle"></ion-toggle>
<!-- Colors --> <!-- Colors -->
<ion-toggle checked color="primary"></ion-toggle> <ion-toggle aria-label="Primary Toggle" checked color="primary"></ion-toggle>
<ion-toggle checked color="secondary"></ion-toggle> <ion-toggle aria-label="Secondary Toggle" checked color="secondary"></ion-toggle>
<ion-toggle checked color="tertiary"></ion-toggle> <ion-toggle aria-label="Tertiary Toggle" checked color="tertiary"></ion-toggle>
<ion-toggle checked color="success"></ion-toggle> <ion-toggle aria-label="Success Toggle" checked color="success"></ion-toggle>
<ion-toggle checked color="warning"></ion-toggle> <ion-toggle aria-label="Warning Toggle" checked color="warning"></ion-toggle>
<ion-toggle checked color="danger"></ion-toggle> <ion-toggle aria-label="Danger Toggle" checked color="danger"></ion-toggle>
<ion-toggle checked color="light"></ion-toggle> <ion-toggle aria-label="Light Toggle" checked color="light"></ion-toggle>
<ion-toggle checked color="medium"></ion-toggle> <ion-toggle aria-label="Medium Toggle" checked color="medium"></ion-toggle>
<ion-toggle checked color="dark"></ion-toggle> <ion-toggle aria-label="Dark Toggle" checked color="dark"></ion-toggle>
<ion-toggle checked class="custom"></ion-toggle> <ion-toggle aria-label="Custom Toggle" checked class="custom"></ion-toggle>
<!-- Disabled --> <!-- Disabled -->
<ion-toggle checked disabled></ion-toggle> <ion-toggle aria-label="Disabled Default Toggle" checked disabled></ion-toggle>
<ion-toggle checked disabled color="secondary"></ion-toggle> <ion-toggle aria-label="Disabled Secondary Toggle" checked disabled color="secondary"></ion-toggle>
<style> <style>
.custom { .custom {

View File

@ -45,8 +45,18 @@
pointer-events: none; pointer-events: none;
} }
button { label {
@include input-cover(); @include input-cover();
display: flex;
align-items: center;
opacity: 0;
}
input {
@include visually-hidden();
} }
// Toggle Background Track: Unchecked // Toggle Background Track: Unchecked

View File

@ -2,7 +2,7 @@ import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Prop
import { getIonMode } from '../../global/ionic-global'; import { getIonMode } from '../../global/ionic-global';
import { Color, Gesture, GestureDetail, StyleEventDetail, ToggleChangeEventDetail } from '../../interface'; import { Color, Gesture, GestureDetail, StyleEventDetail, ToggleChangeEventDetail } from '../../interface';
import { findItemLabel, renderHiddenInput } from '../../utils/helpers'; import { getAriaLabel, renderHiddenInput } from '../../utils/helpers';
import { hapticSelection } from '../../utils/native/haptic'; import { hapticSelection } from '../../utils/native/haptic';
import { createColorClasses, hostContext } from '../../utils/theme'; import { createColorClasses, hostContext } from '../../utils/theme';
@ -24,7 +24,7 @@ export class Toggle implements ComponentInterface {
private inputId = `ion-tg-${toggleIds++}`; private inputId = `ion-tg-${toggleIds++}`;
private gesture?: Gesture; private gesture?: Gesture;
private buttonEl?: HTMLElement; private focusEl?: HTMLElement;
private lastDrag = 0; private lastDrag = 0;
@Element() el!: HTMLElement; @Element() el!: HTMLElement;
@ -156,12 +156,15 @@ export class Toggle implements ComponentInterface {
} }
private setFocus() { private setFocus() {
if (this.buttonEl) { if (this.focusEl) {
this.buttonEl.focus(); this.focusEl.focus();
} }
} }
private onClick = () => { private onClick = (ev: Event) => {
ev.preventDefault();
ev.stopPropagation();
if (this.lastDrag + 300 < Date.now()) { if (this.lastDrag + 300 < Date.now()) {
this.checked = !this.checked; this.checked = !this.checked;
} }
@ -176,23 +179,20 @@ export class Toggle implements ComponentInterface {
} }
render() { render() {
const { inputId, disabled, checked, activated, color, el } = this; const { activated, color, checked, disabled, el, inputId, name } = this;
const mode = getIonMode(this); const mode = getIonMode(this);
const labelId = inputId + '-lbl'; const { label, labelId, labelText } = getAriaLabel(el, inputId);
const label = findItemLabel(el);
const value = this.getValue(); const value = this.getValue();
if (label) {
label.id = labelId; renderHiddenInput(true, el, name, (checked ? value : ''), disabled);
}
renderHiddenInput(true, el, this.name, (checked ? value : ''), disabled);
return ( return (
<Host <Host
onClick={this.onClick} onClick={this.onClick}
role="checkbox" aria-labelledby={label ? labelId : null}
aria-disabled={disabled ? 'true' : null}
aria-checked={`${checked}`} aria-checked={`${checked}`}
aria-labelledby={labelId} aria-hidden={disabled ? 'true' : null}
role="switch"
class={createColorClasses(color, { class={createColorClasses(color, {
[mode]: true, [mode]: true,
'in-item': hostContext('ion-item', el), 'in-item': hostContext('ion-item', el),
@ -207,15 +207,19 @@ export class Toggle implements ComponentInterface {
<div class="toggle-inner" part="handle" /> <div class="toggle-inner" part="handle" />
</div> </div>
</div> </div>
<button <label htmlFor={inputId}>
type="button" {labelText}
onFocus={this.onFocus} </label>
onBlur={this.onBlur} <input
type="checkbox"
role="switch"
aria-checked={`${checked}`}
disabled={disabled} disabled={disabled}
ref={btnEl => this.buttonEl = btnEl} id={inputId}
aria-hidden="true" onFocus={() => this.onFocus()}
> onBlur={() => this.onBlur()}
</button> ref={focusEl => this.focusEl = focusEl}
/>
</Host> </Host>
); );
} }