Skip to content

Commit

Permalink
fix(checkbox): use a native input to fix a11y issues with axe and scr…
Browse files Browse the repository at this point in the history
  • Loading branch information
brandyscarney authored and Takuma Kira committed Nov 13, 2020
1 parent 0b71789 commit f937a80
Show file tree
Hide file tree
Showing 11 changed files with 347 additions and 106 deletions.
125 changes: 125 additions & 0 deletions .github/COMPONENT-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* [Ripple Effect](#ripple-effect)
* [Example Components](#example-components)
* [References](#references)
- [Accessibility](#accessibility)
- [Rendering Anchor or Button](#rendering-anchor-or-button)
* [Example Components](#example-components-1)
* [Component Structure](#component-structure-1)
Expand Down Expand Up @@ -362,6 +363,130 @@ ion-ripple-effect {
- [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
Certain components can render an `<a>` or a `<button>` depending on the presence of an `href` attribute.
Expand Down
2 changes: 1 addition & 1 deletion core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
"css.sass": "sass src/css:./css",
"lint": "npm run lint.ts && npm run lint.sass",
"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.ts": "tslint --project .",
"lint.ts.fix": "tslint --project . --fix",
Expand Down
8 changes: 4 additions & 4 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ export namespace Components {
*/
"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;
}
Expand Down Expand Up @@ -3709,23 +3709,23 @@ declare namespace LocalJSX {
*/
"name"?: string;
/**
* Emitted when the toggle loses focus.
* Emitted when the checkbox loses focus.
*/
"onIonBlur"?: (event: CustomEvent<void>) => void;
/**
* Emitted when the checked property has changed.
*/
"onIonChange"?: (event: CustomEvent<CheckboxChangeEventDetail>) => void;
/**
* Emitted when the toggle has focus.
* Emitted when the checkbox has focus.
*/
"onIonFocus"?: (event: CustomEvent<void>) => void;
/**
* Emitted when the styles change.
*/
"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;
}
Expand Down
12 changes: 11 additions & 1 deletion core/src/components/checkbox/checkbox.scss
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,18 @@
--checkmark-color: #{current-color(contrast)};
}

button {
label {
@include input-cover();

display: flex;

align-items: center;

opacity: 0;
}

input {
@include visually-hidden();
}

.checkbox-icon {
Expand Down
60 changes: 32 additions & 28 deletions core/src/components/checkbox/checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Prop

import { getIonMode } from '../../global/ionic-global';
import { CheckboxChangeEventDetail, Color, StyleEventDetail } from '../../interface';
import { findItemLabel, renderHiddenInput } from '../../utils/helpers';
import { getAriaLabel, renderHiddenInput } from '../../utils/helpers';
import { createColorClasses, hostContext } from '../../utils/theme';

/**
Expand All @@ -22,7 +22,7 @@ import { createColorClasses, hostContext } from '../../utils/theme';
export class Checkbox implements ComponentInterface {

private inputId = `ion-cb-${checkboxIds++}`;
private buttonEl?: HTMLElement;
private focusEl?: HTMLElement;

@Element() el!: HTMLElement;

Expand Down Expand Up @@ -54,11 +54,11 @@ export class Checkbox implements ComponentInterface {
@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.
*
* 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 a checkbox is analogous to the value of an `<input type="checkbox">`,
* it's only used when the checkbox participates in a native `<form>`.
*/
@Prop() value = 'on';

Expand All @@ -68,12 +68,12 @@ export class Checkbox implements ComponentInterface {
@Event() ionChange!: EventEmitter<CheckboxChangeEventDetail>;

/**
* Emitted when the toggle has focus.
* Emitted when the checkbox has focus.
*/
@Event() ionFocus!: EventEmitter<void>;

/**
* Emitted when the toggle loses focus.
* Emitted when the checkbox loses focus.
*/
@Event() ionBlur!: EventEmitter<void>;

Expand Down Expand Up @@ -109,12 +109,15 @@ export class Checkbox implements ComponentInterface {
}

private setFocus() {
if (this.buttonEl) {
this.buttonEl.focus();
if (this.focusEl) {
this.focusEl.focus();
}
}

private onClick = () => {
private onClick = (ev: Event) => {
ev.preventDefault();
ev.stopPropagation();

this.setFocus();
this.checked = !this.checked;
this.indeterminate = false;
Expand All @@ -129,14 +132,11 @@ export class Checkbox implements ComponentInterface {
}

render() {
const { inputId, indeterminate, disabled, checked, value, color, el } = this;
const labelId = inputId + '-lbl';
const { color, checked, disabled, el, indeterminate, inputId, name, value } = this;
const mode = getIonMode(this);
const label = findItemLabel(el);
if (label) {
label.id = labelId;
}
renderHiddenInput(true, el, this.name, (checked ? value : ''), disabled);
const { label, labelId, labelText } = getAriaLabel(el, inputId);

renderHiddenInput(true, el, name, (checked ? value : ''), disabled);

let path = indeterminate
? <path d="M6 12L18 12" part="mark" />
Expand All @@ -151,10 +151,10 @@ export class Checkbox implements ComponentInterface {
return (
<Host
onClick={this.onClick}
role="checkbox"
aria-disabled={disabled ? 'true' : null}
aria-labelledby={label ? labelId : null}
aria-checked={`${checked}`}
aria-labelledby={labelId}
aria-hidden={disabled ? 'true' : null}
role="checkbox"
class={createColorClasses(color, {
[mode]: true,
'in-item': hostContext('ion-item', el),
Expand All @@ -167,14 +167,18 @@ export class Checkbox implements ComponentInterface {
<svg class="checkbox-icon" viewBox="0 0 24 24" part="container">
{path}
</svg>
<button
type="button"
onFocus={this.onFocus}
onBlur={this.onBlur}
disabled={this.disabled}
ref={btnEl => this.buttonEl = btnEl}
>
</button>
<label htmlFor={inputId}>
{labelText}
</label>
<input
type="checkbox"
aria-checked={`${checked}`}
disabled={disabled}
id={inputId}
onFocus={() => this.onFocus()}
onBlur={() => this.onBlur()}
ref={focusEl => this.focusEl = focusEl}
/>
</Host>
);
}
Expand Down
6 changes: 3 additions & 3 deletions core/src/components/checkbox/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,16 +266,16 @@ export default defineComponent({
| `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` |
| `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

| 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>` |
| `ionFocus` | Emitted when the toggle has focus. | `CustomEvent<void>` |
| `ionFocus` | Emitted when the checkbox has focus. | `CustomEvent<void>` |


## Shadow Parts
Expand Down
27 changes: 26 additions & 1 deletion core/src/components/checkbox/test/basic/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@

<ion-item>
<ion-label>Secondary</ion-label>
<ion-checkbox checked color="secondary"></ion-checkbox>
<ion-checkbox disabled checked color="secondary"></ion-checkbox>
</ion-item>

<ion-item>
Expand Down Expand Up @@ -103,6 +103,31 @@
</ion-content>

</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>

</html>
Loading

0 comments on commit f937a80

Please sign in to comment.