mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-18 19:21:34 +08:00
fix(select): fix a11y issues with axe and screen readers (#22494)
fixes #21552 fixes #21548
This commit is contained in:
@ -50,8 +50,18 @@
|
|||||||
opacity: var(--placeholder-opacity);
|
opacity: var(--placeholder-opacity);
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
label {
|
||||||
@include input-cover();
|
@include input-cover();
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
@include visually-hidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-icon {
|
.select-icon {
|
||||||
|
@ -2,7 +2,7 @@ import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Meth
|
|||||||
|
|
||||||
import { getIonMode } from '../../global/ionic-global';
|
import { getIonMode } from '../../global/ionic-global';
|
||||||
import { ActionSheetButton, ActionSheetOptions, AlertInput, AlertOptions, CssClassMap, OverlaySelect, PopoverOptions, SelectChangeEventDetail, SelectInterface, SelectPopoverOption, StyleEventDetail } from '../../interface';
|
import { ActionSheetButton, ActionSheetOptions, AlertInput, AlertOptions, CssClassMap, OverlaySelect, PopoverOptions, SelectChangeEventDetail, SelectInterface, SelectPopoverOption, StyleEventDetail } from '../../interface';
|
||||||
import { findItemLabel, renderHiddenInput } from '../../utils/helpers';
|
import { findItemLabel, getAriaLabel, renderHiddenInput } from '../../utils/helpers';
|
||||||
import { actionSheetController, alertController, popoverController } from '../../utils/overlays';
|
import { actionSheetController, alertController, popoverController } from '../../utils/overlays';
|
||||||
import { hostContext } from '../../utils/theme';
|
import { hostContext } from '../../utils/theme';
|
||||||
import { watchForOptions } from '../../utils/watch-options';
|
import { watchForOptions } from '../../utils/watch-options';
|
||||||
@ -29,7 +29,7 @@ export class Select implements ComponentInterface {
|
|||||||
private inputId = `ion-sel-${selectIds++}`;
|
private inputId = `ion-sel-${selectIds++}`;
|
||||||
private overlay?: OverlaySelect;
|
private overlay?: OverlaySelect;
|
||||||
private didInit = false;
|
private didInit = false;
|
||||||
private buttonEl?: HTMLButtonElement;
|
private focusEl?: HTMLButtonElement;
|
||||||
private mutationO?: MutationObserver;
|
private mutationO?: MutationObserver;
|
||||||
|
|
||||||
@Element() el!: HTMLIonSelectElement;
|
@Element() el!: HTMLIonSelectElement;
|
||||||
@ -403,8 +403,8 @@ export class Select implements ComponentInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setFocus() {
|
private setFocus() {
|
||||||
if (this.buttonEl) {
|
if (this.focusEl) {
|
||||||
this.buttonEl.focus();
|
this.focusEl.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -420,6 +420,9 @@ export class Select implements ComponentInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private onClick = (ev: UIEvent) => {
|
private onClick = (ev: UIEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
this.setFocus();
|
this.setFocus();
|
||||||
this.open(ev);
|
this.open(ev);
|
||||||
}
|
}
|
||||||
@ -432,23 +435,21 @@ export class Select implements ComponentInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { placeholder, name, disabled, isExpanded, value, el } = this;
|
const { disabled, el, inputId, isExpanded, name, placeholder, value } = this;
|
||||||
const mode = getIonMode(this);
|
const mode = getIonMode(this);
|
||||||
const labelId = this.inputId + '-lbl';
|
const { labelText, labelId } = getAriaLabel(el, inputId);
|
||||||
const label = findItemLabel(el);
|
|
||||||
if (label) {
|
renderHiddenInput(true, el, name, parseValue(value), disabled);
|
||||||
label.id = labelId;
|
|
||||||
}
|
const displayValue = this.getText();
|
||||||
|
|
||||||
let addPlaceholderClass = false;
|
let addPlaceholderClass = false;
|
||||||
let selectText = this.getText();
|
let selectText = displayValue;
|
||||||
if (selectText === '' && placeholder != null) {
|
if (selectText === '' && placeholder != null) {
|
||||||
selectText = placeholder;
|
selectText = placeholder;
|
||||||
addPlaceholderClass = true;
|
addPlaceholderClass = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderHiddenInput(true, el, name, parseValue(value), disabled);
|
|
||||||
|
|
||||||
const selectTextClasses: CssClassMap = {
|
const selectTextClasses: CssClassMap = {
|
||||||
'select-text': true,
|
'select-text': true,
|
||||||
'select-placeholder': addPlaceholderClass
|
'select-placeholder': addPlaceholderClass
|
||||||
@ -456,14 +457,20 @@ export class Select implements ComponentInterface {
|
|||||||
|
|
||||||
const textPart = addPlaceholderClass ? 'placeholder' : 'text';
|
const textPart = addPlaceholderClass ? 'placeholder' : 'text';
|
||||||
|
|
||||||
|
// If there is a label then we need to concatenate it with the
|
||||||
|
// current value and a comma so it separates nicely when the screen reader
|
||||||
|
// announces it, otherwise just announce the value
|
||||||
|
const displayLabel = labelText !== undefined
|
||||||
|
? `${displayValue}, ${labelText}`
|
||||||
|
: displayValue;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Host
|
<Host
|
||||||
onClick={this.onClick}
|
onClick={this.onClick}
|
||||||
role="listbox"
|
role="button"
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="listbox"
|
||||||
aria-disabled={disabled ? 'true' : null}
|
aria-disabled={disabled ? 'true' : null}
|
||||||
aria-expanded={`${isExpanded}`}
|
aria-label={displayLabel}
|
||||||
aria-labelledby={labelId}
|
|
||||||
class={{
|
class={{
|
||||||
[mode]: true,
|
[mode]: true,
|
||||||
'in-item': hostContext('ion-item', el),
|
'in-item': hostContext('ion-item', el),
|
||||||
@ -476,14 +483,20 @@ export class Select implements ComponentInterface {
|
|||||||
<div class="select-icon" role="presentation" part="icon">
|
<div class="select-icon" role="presentation" part="icon">
|
||||||
<div class="select-icon-inner"></div>
|
<div class="select-icon-inner"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<label id={labelId}>
|
||||||
|
{displayLabel}
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
id={inputId}
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={`${isExpanded}`}
|
||||||
onFocus={this.onFocus}
|
onFocus={this.onFocus}
|
||||||
onBlur={this.onBlur}
|
onBlur={this.onBlur}
|
||||||
disabled={disabled}
|
ref={(focusEl => this.focusEl = focusEl)}
|
||||||
ref={(btnEl => this.buttonEl = btnEl)}
|
></button>
|
||||||
>
|
|
||||||
</button>
|
|
||||||
</Host>
|
</Host>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
152
core/src/components/select/test/a11y/index.html
Normal file
152
core/src/components/select/test/a11y/index.html
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" dir="ltr">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Select - a11y</title>
|
||||||
|
<meta name="viewport"
|
||||||
|
content="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>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<ion-app>
|
||||||
|
|
||||||
|
<ion-header>
|
||||||
|
<ion-toolbar>
|
||||||
|
<ion-title>Select - a11y</ion-title>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
|
||||||
|
<ion-content class="outer-content">
|
||||||
|
<ion-list>
|
||||||
|
<ion-list-header>
|
||||||
|
<ion-label>
|
||||||
|
Native Select
|
||||||
|
</ion-label>
|
||||||
|
</ion-list-header>
|
||||||
|
|
||||||
|
<div class="native-select-wrapper">
|
||||||
|
<label for="pet-select">Choose a Pet</label>
|
||||||
|
|
||||||
|
<select name="pets" id="pet-select">
|
||||||
|
<option value="dog">Dog</option>
|
||||||
|
<option value="cat">Cat</option>
|
||||||
|
<option value="hamster">Hamster</option>
|
||||||
|
<option value="parrot">Parrot</option>
|
||||||
|
<option value="spider">Spider</option>
|
||||||
|
<option value="goldfish">Goldfish</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</ion-list>
|
||||||
|
|
||||||
|
<ion-list>
|
||||||
|
<ion-list-header>
|
||||||
|
<ion-label>
|
||||||
|
Default Ionic Select
|
||||||
|
</ion-label>
|
||||||
|
</ion-list-header>
|
||||||
|
|
||||||
|
<ion-item>
|
||||||
|
<ion-label>Choose a Pet</ion-label>
|
||||||
|
|
||||||
|
<ion-select>
|
||||||
|
<ion-select-option value="dog">Dog</ion-select-option>
|
||||||
|
<ion-select-option value="cat">Cat</ion-select-option>
|
||||||
|
<ion-select-option value="hamster">Hamster</ion-select-option>
|
||||||
|
<ion-select-option value="parrot">Parrot</ion-select-option>
|
||||||
|
<ion-select-option value="spider">Spider</ion-select-option>
|
||||||
|
<ion-select-option value="goldfish">Goldfish</ion-select-option>
|
||||||
|
</ion-select>
|
||||||
|
</ion-item>
|
||||||
|
</ion-list>
|
||||||
|
|
||||||
|
<ion-list>
|
||||||
|
<ion-list-header>
|
||||||
|
<ion-label>
|
||||||
|
Custom Label Ionic Select
|
||||||
|
</ion-label>
|
||||||
|
</ion-list-header>
|
||||||
|
|
||||||
|
<ion-item>
|
||||||
|
<label for="ionic-select">Choose a Pet</label>
|
||||||
|
|
||||||
|
<ion-select id="ionic-select">
|
||||||
|
<ion-select-option value="dog">Dog</ion-select-option>
|
||||||
|
<ion-select-option value="cat">Cat</ion-select-option>
|
||||||
|
<ion-select-option value="hamster">Hamster</ion-select-option>
|
||||||
|
<ion-select-option value="parrot">Parrot</ion-select-option>
|
||||||
|
<ion-select-option value="spider">Spider</ion-select-option>
|
||||||
|
<ion-select-option value="goldfish">Goldfish</ion-select-option>
|
||||||
|
</ion-select>
|
||||||
|
</ion-item>
|
||||||
|
</ion-list>
|
||||||
|
|
||||||
|
<ion-list>
|
||||||
|
<ion-list-header>
|
||||||
|
<ion-label>
|
||||||
|
Popover Ionic Select
|
||||||
|
</ion-label>
|
||||||
|
</ion-list-header>
|
||||||
|
|
||||||
|
<ion-item>
|
||||||
|
<ion-label>Choose a Pet</ion-label>
|
||||||
|
|
||||||
|
<ion-select interface="popover">
|
||||||
|
<ion-select-option value="dog">Dog</ion-select-option>
|
||||||
|
<ion-select-option value="cat">Cat</ion-select-option>
|
||||||
|
<ion-select-option value="hamster">Hamster</ion-select-option>
|
||||||
|
<ion-select-option value="parrot">Parrot</ion-select-option>
|
||||||
|
<ion-select-option value="spider">Spider</ion-select-option>
|
||||||
|
<ion-select-option value="goldfish">Goldfish</ion-select-option>
|
||||||
|
</ion-select>
|
||||||
|
</ion-item>
|
||||||
|
</ion-list>
|
||||||
|
|
||||||
|
<ion-list>
|
||||||
|
<ion-list-header>
|
||||||
|
<ion-label>
|
||||||
|
Action Sheet Ionic Select
|
||||||
|
</ion-label>
|
||||||
|
</ion-list-header>
|
||||||
|
|
||||||
|
<ion-item>
|
||||||
|
<ion-label>Choose a Pet</ion-label>
|
||||||
|
|
||||||
|
<ion-select interface="action-sheet">
|
||||||
|
<ion-select-option value="dog">Dog</ion-select-option>
|
||||||
|
<ion-select-option value="cat">Cat</ion-select-option>
|
||||||
|
<ion-select-option value="hamster">Hamster</ion-select-option>
|
||||||
|
<ion-select-option value="parrot">Parrot</ion-select-option>
|
||||||
|
<ion-select-option value="spider">Spider</ion-select-option>
|
||||||
|
<ion-select-option value="goldfish">Goldfish</ion-select-option>
|
||||||
|
</ion-select>
|
||||||
|
</ion-item>
|
||||||
|
</ion-list>
|
||||||
|
|
||||||
|
|
||||||
|
</ion-content>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.native-select-wrapper {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</ion-app>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
@ -124,11 +124,15 @@ export const getAriaLabel = (componentEl: HTMLElement, inputId: string): { label
|
|||||||
// we should use that instead of looking for an ion-label
|
// we should use that instead of looking for an ion-label
|
||||||
const labelledBy = componentEl.getAttribute('aria-labelledby');
|
const labelledBy = componentEl.getAttribute('aria-labelledby');
|
||||||
|
|
||||||
const labelId = labelledBy !== null
|
// Grab the id off of the component in case they are using
|
||||||
|
// a custom label using the label element
|
||||||
|
const componentId = componentEl.id;
|
||||||
|
|
||||||
|
let labelId = labelledBy !== null
|
||||||
? labelledBy
|
? labelledBy
|
||||||
: inputId + '-lbl';
|
: inputId + '-lbl';
|
||||||
|
|
||||||
const label = labelledBy !== null
|
let label = labelledBy !== null && labelledBy.trim() !== ''
|
||||||
? document.querySelector(`#${labelledBy}`)
|
? document.querySelector(`#${labelledBy}`)
|
||||||
: findItemLabel(componentEl);
|
: findItemLabel(componentEl);
|
||||||
|
|
||||||
@ -139,6 +143,16 @@ export const getAriaLabel = (componentEl: HTMLElement, inputId: string): { label
|
|||||||
|
|
||||||
labelText = label.textContent;
|
labelText = label.textContent;
|
||||||
label.setAttribute('aria-hidden', 'true');
|
label.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
// if there is no label, check to see if the user has provided
|
||||||
|
// one by setting an id on the component and using the label element
|
||||||
|
} else if (componentId.trim() !== '') {
|
||||||
|
label = document.querySelector(`label[for=${componentId}]`);
|
||||||
|
|
||||||
|
if (label) {
|
||||||
|
label.id = labelId = `${componentId}-lbl`;
|
||||||
|
labelText = label.textContent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { label, labelId, labelText };
|
return { label, labelId, labelText };
|
||||||
|
Reference in New Issue
Block a user