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

fixes #21644
fixes #20517
fixes #17796
This commit is contained in:
Brandy Carney
2020-11-12 11:25:33 -05:00
committed by GitHub
parent 0956f8bc55
commit 7214a8401b
11 changed files with 347 additions and 106 deletions

View File

@ -9,6 +9,7 @@
* [Ripple Effect](#ripple-effect) * [Ripple Effect](#ripple-effect)
* [Example Components](#example-components) * [Example Components](#example-components)
* [References](#references) * [References](#references)
- [Accessibility](#accessibility)
- [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)
@ -362,6 +363,130 @@ ion-ripple-effect {
- [iOS Buttons](https://developer.apple.com/design/human-interface-guidelines/ios/controls/buttons/) - [iOS Buttons](https://developer.apple.com/design/human-interface-guidelines/ios/controls/buttons/)
## Accessibility
### Checkbox
#### Example Components
- [ion-checkbox](https://github.com/ionic-team/ionic/tree/master/core/src/components/checkbox)
#### VoiceOver
In order for VoiceOver to work properly with a checkbox component there must be a native `input` with `type="checkbox"`, and `aria-checked` and `role="checkbox"` **must** be on the host element. The `aria-hidden` attribute needs to be added if the checkbox is disabled, preventing iOS users from selecting it:
```tsx
render() {
const { checked, disabled } = this;
return (
<Host
aria-checked={`${checked}`}
aria-hidden={disabled ? 'true' : null}
role="checkbox"
>
<input
type="checkbox"
/>
...
</Host>
);
}
```
#### NVDA
It is required to have `aria-checked` on the native input for checked to read properly and `disabled` to prevent tabbing to the input:
```tsx
render() {
const { checked, disabled } = this;
return (
<Host
aria-checked={`${checked}`}
aria-hidden={disabled ? 'true' : null}
role="checkbox"
>
<input
type="checkbox"
aria-checked={`${checked}`}
disabled={disabled}
/>
...
</Host>
);
}
```
#### Labels
A helper function has been created to get the proper `aria-label` for the checkbox. This can be imported as `getAriaLabel` like the following:
```tsx
const { label, labelId, labelText } = getAriaLabel(el, inputId);
```
where `el` and `inputId` are the following:
```tsx
private inputId = `ion-cb-${checkboxIds++}`;
@Element() el!: HTMLElement;
```
This can then be added to the `Host` like the following:
```tsx
<Host
aria-labelledby={label ? labelId : null}
aria-checked={`${checked}`}
aria-hidden={disabled ? 'true' : null}
role="checkbox"
>
```
In addition to that, the checkbox should have a label added:
```tsx
<Host
aria-labelledby={label ? labelId : null}
aria-checked={`${checked}`}
aria-hidden={disabled ? 'true' : null}
role="checkbox"
>
<label htmlFor={inputId}>
{labelText}
</label>
<input
type="checkbox"
aria-checked={`${checked}`}
disabled={disabled}
id={inputId}
/>
```
#### Hidden Input
A helper function to render a hidden input has been added, it can be added in the `render`:
```tsx
renderHiddenInput(true, el, name, (checked ? value : ''), disabled);
```
> This is required for the checkbox to work with forms.
#### Known Issues
When using VoiceOver on macOS, Chrome will announce the following when you are focused on a checkbox:
```
currently on a checkbox inside of a checkbox
```
This is a compromise we have to make in order for it to work with the other screen readers & Safari.
## Rendering Anchor or Button ## Rendering Anchor or Button
Certain components can render an `<a>` or a `<button>` depending on the presence of an `href` attribute. Certain components can render an `<a>` or a `<button>` depending on the presence of an `href` attribute.

View File

@ -75,7 +75,7 @@
"css.sass": "sass src/css:./css", "css.sass": "sass src/css:./css",
"lint": "npm run lint.ts && npm run lint.sass", "lint": "npm run lint.ts && npm run lint.sass",
"lint.fix": "npm run lint.ts.fix && npm run lint.sass.fix", "lint.fix": "npm run lint.ts.fix && npm run lint.sass.fix",
"lint.sass": "stylelint 'src/**/*.scss'", "lint.sass": "stylelint \"src/**/*.scss\"",
"lint.sass.fix": "npm run lint.sass -- --fix", "lint.sass.fix": "npm run lint.sass -- --fix",
"lint.ts": "tslint --project .", "lint.ts": "tslint --project .",
"lint.ts.fix": "tslint --project . --fix", "lint.ts.fix": "tslint --project . --fix",

View File

@ -394,7 +394,7 @@ export namespace Components {
*/ */
"name": string; "name": string;
/** /**
* The value of the toggle does not mean if it's checked or not, use the `checked` property for that. The value of a toggle is analogous to the value of a `<input type="checkbox">`, it's only used when the toggle participates in a native `<form>`. * The value of the checkbox does not mean if it's checked or not, use the `checked` property for that. The value of a checkbox is analogous to the value of an `<input type="checkbox">`, it's only used when the checkbox participates in a native `<form>`.
*/ */
"value": string; "value": string;
} }
@ -3705,7 +3705,7 @@ declare namespace LocalJSX {
*/ */
"name"?: string; "name"?: string;
/** /**
* Emitted when the toggle loses focus. * Emitted when the checkbox loses focus.
*/ */
"onIonBlur"?: (event: CustomEvent<void>) => void; "onIonBlur"?: (event: CustomEvent<void>) => void;
/** /**
@ -3713,7 +3713,7 @@ declare namespace LocalJSX {
*/ */
"onIonChange"?: (event: CustomEvent<CheckboxChangeEventDetail>) => void; "onIonChange"?: (event: CustomEvent<CheckboxChangeEventDetail>) => void;
/** /**
* Emitted when the toggle has focus. * Emitted when the checkbox has focus.
*/ */
"onIonFocus"?: (event: CustomEvent<void>) => void; "onIonFocus"?: (event: CustomEvent<void>) => void;
/** /**
@ -3721,7 +3721,7 @@ declare namespace LocalJSX {
*/ */
"onIonStyle"?: (event: CustomEvent<StyleEventDetail>) => void; "onIonStyle"?: (event: CustomEvent<StyleEventDetail>) => void;
/** /**
* The value of the toggle does not mean if it's checked or not, use the `checked` property for that. The value of a toggle is analogous to the value of a `<input type="checkbox">`, it's only used when the toggle participates in a native `<form>`. * The value of the checkbox does not mean if it's checked or not, use the `checked` property for that. The value of a checkbox is analogous to the value of an `<input type="checkbox">`, it's only used when the checkbox participates in a native `<form>`.
*/ */
"value"?: string; "value"?: string;
} }

View File

@ -40,8 +40,18 @@
--checkmark-color: #{current-color(contrast)}; --checkmark-color: #{current-color(contrast)};
} }
button { label {
@include input-cover(); @include input-cover();
display: flex;
align-items: center;
opacity: 0;
}
input {
@include visually-hidden();
} }
.checkbox-icon { .checkbox-icon {

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 { CheckboxChangeEventDetail, Color, StyleEventDetail } from '../../interface'; import { CheckboxChangeEventDetail, Color, StyleEventDetail } from '../../interface';
import { findItemLabel, renderHiddenInput } from '../../utils/helpers'; import { getAriaLabel, renderHiddenInput } from '../../utils/helpers';
import { createColorClasses, hostContext } from '../../utils/theme'; import { createColorClasses, hostContext } from '../../utils/theme';
/** /**
@ -22,7 +22,7 @@ import { createColorClasses, hostContext } from '../../utils/theme';
export class Checkbox implements ComponentInterface { export class Checkbox implements ComponentInterface {
private inputId = `ion-cb-${checkboxIds++}`; private inputId = `ion-cb-${checkboxIds++}`;
private buttonEl?: HTMLElement; private focusEl?: HTMLElement;
@Element() el!: HTMLElement; @Element() el!: HTMLElement;
@ -54,11 +54,11 @@ export class Checkbox implements ComponentInterface {
@Prop() disabled = false; @Prop() disabled = false;
/** /**
* The value of the toggle does not mean if it's checked or not, use the `checked` * The value of the checkbox does not mean if it's checked or not, use the `checked`
* property for that. * property for that.
* *
* The value of a toggle is analogous to the value of a `<input type="checkbox">`, * The value of a checkbox is analogous to the value of an `<input type="checkbox">`,
* it's only used when the toggle participates in a native `<form>`. * it's only used when the checkbox participates in a native `<form>`.
*/ */
@Prop() value = 'on'; @Prop() value = 'on';
@ -68,12 +68,12 @@ export class Checkbox implements ComponentInterface {
@Event() ionChange!: EventEmitter<CheckboxChangeEventDetail>; @Event() ionChange!: EventEmitter<CheckboxChangeEventDetail>;
/** /**
* Emitted when the toggle has focus. * Emitted when the checkbox has focus.
*/ */
@Event() ionFocus!: EventEmitter<void>; @Event() ionFocus!: EventEmitter<void>;
/** /**
* Emitted when the toggle loses focus. * Emitted when the checkbox loses focus.
*/ */
@Event() ionBlur!: EventEmitter<void>; @Event() ionBlur!: EventEmitter<void>;
@ -109,12 +109,15 @@ export class Checkbox 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();
this.setFocus(); this.setFocus();
this.checked = !this.checked; this.checked = !this.checked;
this.indeterminate = false; this.indeterminate = false;
@ -129,14 +132,11 @@ export class Checkbox implements ComponentInterface {
} }
render() { render() {
const { inputId, indeterminate, disabled, checked, value, color, el } = this; const { color, checked, disabled, el, indeterminate, inputId, name, value } = this;
const labelId = inputId + '-lbl';
const mode = getIonMode(this); const mode = getIonMode(this);
const label = findItemLabel(el); const { label, labelId, labelText } = getAriaLabel(el, inputId);
if (label) {
label.id = labelId; renderHiddenInput(true, el, name, (checked ? value : ''), disabled);
}
renderHiddenInput(true, el, this.name, (checked ? value : ''), disabled);
let path = indeterminate let path = indeterminate
? <path d="M6 12L18 12" part="mark" /> ? <path d="M6 12L18 12" part="mark" />
@ -151,10 +151,10 @@ export class Checkbox implements ComponentInterface {
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="checkbox"
class={createColorClasses(color, { class={createColorClasses(color, {
[mode]: true, [mode]: true,
'in-item': hostContext('ion-item', el), 'in-item': hostContext('ion-item', el),
@ -167,14 +167,18 @@ export class Checkbox implements ComponentInterface {
<svg class="checkbox-icon" viewBox="0 0 24 24" part="container"> <svg class="checkbox-icon" viewBox="0 0 24 24" part="container">
{path} {path}
</svg> </svg>
<button <label htmlFor={inputId}>
type="button" {labelText}
onFocus={this.onFocus} </label>
onBlur={this.onBlur} <input
disabled={this.disabled} type="checkbox"
ref={btnEl => this.buttonEl = btnEl} aria-checked={`${checked}`}
> disabled={disabled}
</button> id={inputId}
onFocus={() => this.onFocus()}
onBlur={() => this.onBlur()}
ref={focusEl => this.focusEl = focusEl}
/>
</Host> </Host>
); );
} }

View File

@ -266,16 +266,16 @@ export default defineComponent({
| `indeterminate` | `indeterminate` | If `true`, the checkbox will visually appear as indeterminate. | `boolean` | `false` | | `indeterminate` | `indeterminate` | If `true`, the checkbox will visually appear as indeterminate. | `boolean` | `false` |
| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` | | `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` |
| `name` | `name` | The name of the control, which is submitted with the form data. | `string` | `this.inputId` | | `name` | `name` | The name of the control, which is submitted with the form data. | `string` | `this.inputId` |
| `value` | `value` | The value of the toggle does not mean if it's checked or not, use the `checked` property for that. The value of a toggle is analogous to the value of a `<input type="checkbox">`, it's only used when the toggle participates in a native `<form>`. | `string` | `'on'` | | `value` | `value` | The value of the checkbox does not mean if it's checked or not, use the `checked` property for that. The value of a checkbox is analogous to the value of an `<input type="checkbox">`, it's only used when the checkbox participates in a native `<form>`. | `string` | `'on'` |
## Events ## Events
| Event | Description | Type | | Event | Description | Type |
| ----------- | ---------------------------------------------- | ---------------------------------------- | | ----------- | ---------------------------------------------- | ---------------------------------------- |
| `ionBlur` | Emitted when the toggle loses focus. | `CustomEvent<void>` | | `ionBlur` | Emitted when the checkbox loses focus. | `CustomEvent<void>` |
| `ionChange` | Emitted when the checked property has changed. | `CustomEvent<CheckboxChangeEventDetail>` | | `ionChange` | Emitted when the checked property has changed. | `CustomEvent<CheckboxChangeEventDetail>` |
| `ionFocus` | Emitted when the toggle has focus. | `CustomEvent<void>` | | `ionFocus` | Emitted when the checkbox has focus. | `CustomEvent<void>` |
## Shadow Parts ## Shadow Parts

View File

@ -38,7 +38,7 @@
<ion-item> <ion-item>
<ion-label>Secondary</ion-label> <ion-label>Secondary</ion-label>
<ion-checkbox checked color="secondary"></ion-checkbox> <ion-checkbox disabled checked color="secondary"></ion-checkbox>
</ion-item> </ion-item>
<ion-item> <ion-item>
@ -103,6 +103,31 @@
</ion-content> </ion-content>
</ion-app> </ion-app>
<script>
const inputs = document.querySelectorAll('ion-checkbox');
for (var i = 0; i < inputs.length; i++) {
const input = inputs[i];
input.addEventListener('ionBlur', function() {
console.log('Listen ionBlur: fired');
});
input.addEventListener('ionFocus', function() {
console.log('Listen ionFocus: fired');
});
input.addEventListener('ionChange', function(ev) {
console.log('Listen ionChange: fired', ev.detail);
});
input.addEventListener('click', function() {
console.log('Listen click: fired');
});
}
</script>
</body> </body>
</html> </html>

View File

@ -31,22 +31,22 @@
<div class="ion-padding-start"> <div class="ion-padding-start">
<!-- Default to unchecked --> <!-- Default to unchecked -->
<label for="unchecked">Unchecked</label> <label for="unchecked">Unchecked</label>
<input name="unchecked" type="checkbox"> <input name="unchecked" id="unchecked" type="checkbox">
<br> <br>
<!-- Default to checked --> <!-- Default to checked -->
<label for="checked">Checked</label> <label for="checked">Checked</label>
<input name="checked" type="checkbox" checked /> <input name="checked" id="checked" type="checkbox" checked />
<br> <br>
<!-- Default to indeterminate --> <!-- Default to indeterminate -->
<label for="indeterminate">Indeterminate</label> <label for="indeterminate">Indeterminate</label>
<input name="indeterminate" type="checkbox" class="indeterminate"> <input name="indeterminate" id="indeterminate" type="checkbox" class="indeterminate">
<br> <br>
<!-- Default to checked / indeterminate --> <!-- Default to checked / indeterminate -->
<label for="both">Checked / Indeterminate</label> <label for="both">Checked / Indeterminate</label>
<input name="both" type="checkbox" checked class="indeterminate"> <input name="both" id="both" type="checkbox" checked class="indeterminate">
<br> <br>
</div> </div>
@ -81,15 +81,15 @@
</ion-label> </ion-label>
</ion-list-header> </ion-list-header>
<div class="ion-padding-start"> <div class="ion-padding-start">
<ion-checkbox indeterminate></ion-checkbox> <ion-checkbox aria-label="Default Indeterminate" indeterminate></ion-checkbox>
<ion-checkbox indeterminate color="secondary"></ion-checkbox> <ion-checkbox aria-label="Secondary Indeterminate" indeterminate color="secondary"></ion-checkbox>
<ion-checkbox indeterminate color="tertiary"></ion-checkbox> <ion-checkbox aria-label="Tertiary Indeterminate" indeterminate color="tertiary"></ion-checkbox>
<ion-checkbox indeterminate color="success"></ion-checkbox> <ion-checkbox aria-label="Success Indeterminate" indeterminate color="success"></ion-checkbox>
<ion-checkbox indeterminate color="warning"></ion-checkbox> <ion-checkbox aria-label="Warning Indeterminate" indeterminate color="warning"></ion-checkbox>
<ion-checkbox indeterminate color="danger"></ion-checkbox> <ion-checkbox aria-label="Danger Indeterminate" indeterminate color="danger"></ion-checkbox>
<ion-checkbox indeterminate color="dark"></ion-checkbox> <ion-checkbox aria-label="Dark Indeterminate" indeterminate color="dark"></ion-checkbox>
<ion-checkbox indeterminate color="medium"></ion-checkbox> <ion-checkbox aria-label="Medium Indeterminate" indeterminate color="medium"></ion-checkbox>
<ion-checkbox indeterminate color="light"></ion-checkbox> <ion-checkbox aria-label="Light Indeterminate" indeterminate color="light"></ion-checkbox>
</div> </div>
<ion-list-header> <ion-list-header>
@ -100,20 +100,20 @@
<ul> <ul>
<li> <li>
<ion-checkbox name="tall" id="tall" indeterminate></ion-checkbox> <ion-checkbox aria-labelledby="tall-label-0" indeterminate></ion-checkbox>
<label for="tall">Tall Things</label> <label id="tall-label-0">Tall Things</label>
<ul> <ul>
<li> <li>
<ion-checkbox name="tall-1" id="tall-1" checked></ion-checkbox> <ion-checkbox aria-labelledby="tall-label-1" checked></ion-checkbox>
<label for="tall-1">Skyscrapers</label> <label id="tall-label-1">Skyscrapers</label>
</li> </li>
<li> <li>
<ion-checkbox name="tall-2" id="tall-2"></ion-checkbox> <ion-checkbox aria-labelledby="tall-label-2"></ion-checkbox>
<label for="tall-2">Trees</label> <label id="tall-label-2">Trees</label>
</li> </li>
<li> <li>
<ion-checkbox name="tall-2" id="tall-2"></ion-checkbox> <ion-checkbox aria-labelledby="tall-label-3"></ion-checkbox>
<label for="tall-2">Giants</label> <label id="tall-label-3">Giants</label>
</li> </li>
</ul> </ul>
</li> </li>

View File

@ -13,67 +13,67 @@
<body class="ion-padding"> <body class="ion-padding">
<h1>Default</h1> <h1>Default</h1>
<ion-checkbox></ion-checkbox> <ion-checkbox aria-label="Default Checkbox"></ion-checkbox>
<ion-checkbox checked></ion-checkbox> <ion-checkbox aria-label="Default Checkbox" checked></ion-checkbox>
<ion-checkbox disabled></ion-checkbox> <ion-checkbox aria-label="Default Checkbox" disabled></ion-checkbox>
<ion-checkbox disabled checked></ion-checkbox> <ion-checkbox aria-label="Default Checkbox" disabled checked></ion-checkbox>
<h1>Colors</h1> <h1>Colors</h1>
<ion-checkbox color="primary"></ion-checkbox> <ion-checkbox aria-label="Checkbox Primary" color="primary"></ion-checkbox>
<ion-checkbox color="secondary"></ion-checkbox> <ion-checkbox aria-label="Checkbox Secondary" color="secondary"></ion-checkbox>
<ion-checkbox color="tertiary"></ion-checkbox> <ion-checkbox aria-label="Checkbox Tertiary" color="tertiary"></ion-checkbox>
<ion-checkbox color="success"></ion-checkbox> <ion-checkbox aria-label="Checkbox Success" color="success"></ion-checkbox>
<ion-checkbox color="warning"></ion-checkbox> <ion-checkbox aria-label="Checkbox Warning" color="warning"></ion-checkbox>
<ion-checkbox color="danger"></ion-checkbox> <ion-checkbox aria-label="Checkbox Danger" color="danger"></ion-checkbox>
<ion-checkbox color="light"></ion-checkbox> <ion-checkbox aria-label="Checkbox Light" color="light"></ion-checkbox>
<ion-checkbox color="medium"></ion-checkbox> <ion-checkbox aria-label="Checkbox Medium" color="medium"></ion-checkbox>
<ion-checkbox color="dark"></ion-checkbox> <ion-checkbox aria-label="Checkbox Dark" color="dark"></ion-checkbox>
<hr> <hr>
<ion-checkbox checked color="primary"></ion-checkbox> <ion-checkbox aria-label="Checkbox Primary" checked color="primary"></ion-checkbox>
<ion-checkbox checked color="secondary"></ion-checkbox> <ion-checkbox aria-label="Checkbox Secondary" checked color="secondary"></ion-checkbox>
<ion-checkbox checked color="tertiary"></ion-checkbox> <ion-checkbox aria-label="Checkbox Tertiary" checked color="tertiary"></ion-checkbox>
<ion-checkbox checked color="success"></ion-checkbox> <ion-checkbox aria-label="Checkbox Success" checked color="success"></ion-checkbox>
<ion-checkbox checked color="warning"></ion-checkbox> <ion-checkbox aria-label="Checkbox Warning" checked color="warning"></ion-checkbox>
<ion-checkbox checked color="danger"></ion-checkbox> <ion-checkbox aria-label="Checkbox Danger" checked color="danger"></ion-checkbox>
<ion-checkbox checked color="light"></ion-checkbox> <ion-checkbox aria-label="Checkbox Light" checked color="light"></ion-checkbox>
<ion-checkbox checked color="medium"></ion-checkbox> <ion-checkbox aria-label="Checkbox Medium" checked color="medium"></ion-checkbox>
<ion-checkbox checked color="dark"></ion-checkbox> <ion-checkbox aria-label="Checkbox Dark" checked color="dark"></ion-checkbox>
<hr> <hr>
<ion-checkbox checked disabled color="primary"></ion-checkbox> <ion-checkbox aria-label="Checkbox Primary" checked disabled color="primary"></ion-checkbox>
<ion-checkbox checked disabled color="secondary"></ion-checkbox> <ion-checkbox aria-label="Checkbox Secondary" checked disabled color="secondary"></ion-checkbox>
<ion-checkbox checked disabled color="tertiary"></ion-checkbox> <ion-checkbox aria-label="Checkbox Tertiary" checked disabled color="tertiary"></ion-checkbox>
<ion-checkbox checked disabled color="success"></ion-checkbox> <ion-checkbox aria-label="Checkbox Success" checked disabled color="success"></ion-checkbox>
<ion-checkbox checked disabled color="warning"></ion-checkbox> <ion-checkbox aria-label="Checkbox Warning" checked disabled color="warning"></ion-checkbox>
<ion-checkbox checked disabled color="danger"></ion-checkbox> <ion-checkbox aria-label="Checkbox Danger" checked disabled color="danger"></ion-checkbox>
<ion-checkbox checked disabled color="light"></ion-checkbox> <ion-checkbox aria-label="Checkbox Light" checked disabled color="light"></ion-checkbox>
<ion-checkbox checked disabled color="medium"></ion-checkbox> <ion-checkbox aria-label="Checkbox Medium" checked disabled color="medium"></ion-checkbox>
<ion-checkbox checked disabled color="dark"></ion-checkbox> <ion-checkbox aria-label="Checkbox Dark" checked disabled color="dark"></ion-checkbox>
<h1>Custom</h1> <h1>Custom</h1>
<ion-checkbox class="custom"></ion-checkbox> <ion-checkbox aria-label="Checkbox Custom" class="custom"></ion-checkbox>
<ion-checkbox class="custom" checked></ion-checkbox> <ion-checkbox aria-label="Checkbox Custom" class="custom" checked></ion-checkbox>
<ion-checkbox class="custom" disabled></ion-checkbox> <ion-checkbox aria-label="Checkbox Custom" class="custom" disabled></ion-checkbox>
<ion-checkbox class="custom" disabled checked></ion-checkbox> <ion-checkbox aria-label="Checkbox Custom" class="custom" disabled checked></ion-checkbox>
<h1>Custom: checked</h1> <h1>Custom: checked</h1>
<ion-checkbox class="custom-checked"></ion-checkbox> <ion-checkbox aria-label="Checkbox Custom" class="custom-checked"></ion-checkbox>
<ion-checkbox class="custom-checked" checked></ion-checkbox> <ion-checkbox aria-label="Checkbox Custom" class="custom-checked" checked></ion-checkbox>
<ion-checkbox class="custom-checked" disabled></ion-checkbox> <ion-checkbox aria-label="Checkbox Custom" class="custom-checked" disabled></ion-checkbox>
<ion-checkbox class="custom-checked" disabled checked></ion-checkbox> <ion-checkbox aria-label="Checkbox Custom" class="custom-checked" disabled checked></ion-checkbox>
<h1>Custom: light</h1> <h1>Custom: light</h1>
<ion-checkbox class="custom-light"></ion-checkbox> <ion-checkbox aria-label="Checkbox Custom Light" class="custom-light"></ion-checkbox>
<ion-checkbox class="custom-light" checked></ion-checkbox> <ion-checkbox aria-label="Checkbox Custom Light" class="custom-light" checked></ion-checkbox>
<ion-checkbox class="custom-light" disabled></ion-checkbox> <ion-checkbox aria-label="Checkbox Custom Light" class="custom-light" disabled></ion-checkbox>
<ion-checkbox class="custom-light" disabled checked></ion-checkbox> <ion-checkbox aria-label="Checkbox Custom Light" class="custom-light" disabled checked></ion-checkbox>
<h1>Custom: transition</h1> <h1>Custom: transition</h1>
<ion-checkbox class="custom-transition"></ion-checkbox> <ion-checkbox aria-label="Checkbox Custom Transition" class="custom-transition"></ion-checkbox>
<ion-checkbox class="custom-transition" checked></ion-checkbox> <ion-checkbox aria-label="Checkbox Custom Transition" class="custom-transition" checked></ion-checkbox>
<style> <style>
.custom { .custom {

View File

@ -21,6 +21,31 @@
} }
} }
@mixin visually-hidden() {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: 0;
outline: 0;
clip: rect(0 0 0 0);
opacity: 0;
overflow: hidden;
-webkit-appearance: none;
-moz-appearance: none;
}
@mixin text-inherit() { @mixin text-inherit() {
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
@ -509,4 +534,4 @@
transform: $rtl-translate $extra; transform: $rtl-translate $extra;
} }
} }
} }

View File

@ -70,7 +70,7 @@ export const hasShadowDom = (el: HTMLElement) => {
return !!el.shadowRoot && !!(el as any).attachShadow; return !!el.shadowRoot && !!(el as any).attachShadow;
}; };
export const findItemLabel = (componentEl: HTMLElement) => { export const findItemLabel = (componentEl: HTMLElement): HTMLIonLabelElement | null => {
const itemEl = componentEl.closest('ion-item'); const itemEl = componentEl.closest('ion-item');
if (itemEl) { if (itemEl) {
return itemEl.querySelector('ion-label'); return itemEl.querySelector('ion-label');
@ -78,6 +78,58 @@ export const findItemLabel = (componentEl: HTMLElement) => {
return null; return null;
}; };
/**
* This method is used for Ionic's input components that use Shadow DOM. In
* order to properly label the inputs to work with screen readers, we need
* to get the text content of the label outside of the shadow root and pass
* it to the input inside of the shadow root.
*
* Referencing label elements by id from outside of the component is
* impossible due to the shadow boundary, read more here:
* https://developer.salesforce.com/blogs/2020/01/accessibility-for-web-components.html
*
* @param componentEl The shadow element that needs the aria label
* @param inputId The unique identifier for the input
*/
export const getAriaLabel = (componentEl: HTMLElement, inputId: string): { label: Element | null, labelId: string, labelText: string | null | undefined } => {
let labelText;
// If the user provides their own label via the aria-labelledby attr
// we should use that instead of looking for an ion-label
const labelledBy = componentEl.getAttribute('aria-labelledby');
const labelId = labelledBy !== null
? labelledBy
: inputId + '-lbl';
const label = labelledBy !== null
? document.querySelector(`#${labelledBy}`)
: findItemLabel(componentEl);
if (label) {
if (labelledBy === null) {
label.id = labelId;
}
labelText = label.textContent;
label.setAttribute('aria-hidden', 'true');
}
return { label, labelId, labelText };
};
/**
* This method is used to add a hidden input to a host element that contains
* a Shadow DOM. It does not add the input inside of the Shadow root which
* allows it to be picked up inside of forms. It should contain the same
* values as the host element.
*
* @param always Add a hidden input even if the container does not use Shadow
* @param container The element where the input will be added
* @param name The name of the input
* @param value The value of the input
* @param disabled If true, the input is disabled
*/
export const renderHiddenInput = (always: boolean, container: HTMLElement, name: string, value: string | undefined | null, disabled: boolean) => { export const renderHiddenInput = (always: boolean, container: HTMLElement, name: string, value: string | undefined | null, disabled: boolean) => {
if (always || hasShadowDom(container)) { if (always || hasShadowDom(container)) {
let input = container.querySelector('input.aux-input') as HTMLInputElement | null; let input = container.querySelector('input.aux-input') as HTMLInputElement | null;