fix(toggle): ensure proper visual selection when navigating via VoiceOver in Safari (#30349)

Issue number: resolves internal

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->
Currently, MacOS voice over on Safari does not recognize ion-toggle
correctly and fails to highlight the element properly

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

By adding the role property to the host element, we're correctly
identifying the toggle so Safari knows how to handle it.

## Does this introduce a breaking change?

- [ ] Yes
- [X] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->
This commit is contained in:
Shane
2025-04-11 10:18:35 -07:00
committed by GitHub
parent 1bc4f59f61
commit b1bc58f1c8
2 changed files with 44 additions and 9 deletions

View File

@ -31,8 +31,6 @@
max-width: 100%;
outline: none;
cursor: pointer;
user-select: none;
z-index: $z-index-item-input;
@ -69,8 +67,12 @@
pointer-events: none;
}
/**
* The native input must be hidden with display instead of visibility or
* aria-hidden to avoid accessibility issues with nested interactive elements.
*/
input {
@include visually-hidden();
display: none;
}
// Toggle Wrapper

View File

@ -35,6 +35,7 @@ import type { ToggleChangeEventDetail } from './toggle-interface';
})
export class Toggle implements ComponentInterface {
private inputId = `ion-tg-${toggleIds++}`;
private inputLabelId = `${this.inputId}-lbl`;
private helperTextId = `${this.inputId}-helper-text`;
private errorTextId = `${this.inputId}-error-text`;
private gesture?: Gesture;
@ -246,6 +247,15 @@ export class Toggle implements ComponentInterface {
}
}
private onKeyDown = (ev: KeyboardEvent) => {
if (ev.key === ' ') {
ev.preventDefault();
if (!this.disabled) {
this.toggleChecked();
}
}
};
private onClick = (ev: MouseEvent) => {
if (this.disabled) {
return;
@ -355,8 +365,23 @@ export class Toggle implements ComponentInterface {
}
render() {
const { activated, color, checked, disabled, el, justify, labelPlacement, inputId, name, alignment, required } =
this;
const {
activated,
alignment,
checked,
color,
disabled,
el,
errorTextId,
hasLabel,
inheritedAttributes,
inputId,
inputLabelId,
justify,
labelPlacement,
name,
required,
} = this;
const mode = getIonMode(this);
const value = this.getValue();
@ -365,9 +390,16 @@ export class Toggle implements ComponentInterface {
return (
<Host
role="switch"
aria-checked={`${checked}`}
aria-describedby={this.getHintTextID()}
aria-invalid={this.getHintTextID() === this.errorTextId}
aria-invalid={this.getHintTextID() === errorTextId}
onClick={this.onClick}
aria-labelledby={hasLabel ? inputLabelId : null}
aria-label={inheritedAttributes['aria-label'] || null}
aria-disabled={disabled ? 'true' : null}
tabindex={disabled ? undefined : 0}
onKeyDown={this.onKeyDown}
class={createColorClasses(color, {
[mode]: true,
'in-item': hostContext('ion-item', el),
@ -380,7 +412,7 @@ export class Toggle implements ComponentInterface {
[`toggle-${rtl}`]: true,
})}
>
<label class="toggle-wrapper">
<label class="toggle-wrapper" htmlFor={inputId}>
{/*
The native control must be rendered
before the visible label text due to https://bugs.webkit.org/show_bug.cgi?id=251951
@ -396,14 +428,15 @@ export class Toggle implements ComponentInterface {
onBlur={() => this.onBlur()}
ref={(focusEl) => (this.focusEl = focusEl)}
required={required}
{...this.inheritedAttributes}
{...inheritedAttributes}
/>
<div
class={{
'label-text-wrapper': true,
'label-text-wrapper-hidden': !this.hasLabel,
'label-text-wrapper-hidden': !hasLabel,
}}
part="label"
id={inputLabelId}
>
<slot></slot>
{this.renderHintText()}