fix(segment): nested interactive is not rendered (#26575)

This commit is contained in:
Liam DeBeasi
2023-01-09 13:08:01 -05:00
committed by GitHub
parent df4882d4d1
commit 77ce9e066e
6 changed files with 57 additions and 31 deletions

View File

@ -2484,6 +2484,7 @@ export namespace Components {
* The mode determines which platform styles to use. * The mode determines which platform styles to use.
*/ */
"mode"?: "ios" | "md"; "mode"?: "ios" | "md";
"setFocus": () => Promise<void>;
/** /**
* The type of the button. * The type of the button.
*/ */

View File

@ -1,10 +1,11 @@
import type { ComponentInterface } from '@stencil/core'; import type { ComponentInterface } from '@stencil/core';
import { Component, Element, Host, Prop, State, forceUpdate, h } from '@stencil/core'; import { Component, Element, Host, Prop, Method, State, forceUpdate, h } from '@stencil/core';
import { getIonMode } from '../../global/ionic-global'; import { getIonMode } from '../../global/ionic-global';
import type { SegmentButtonLayout } from '../../interface'; import type { SegmentButtonLayout } from '../../interface';
import type { ButtonInterface } from '../../utils/element-interface'; import type { ButtonInterface } from '../../utils/element-interface';
import { addEventListener, removeEventListener } from '../../utils/helpers'; import type { Attributes } from '../../utils/helpers';
import { addEventListener, removeEventListener, inheritAttributes } from '../../utils/helpers';
import { hostContext } from '../../utils/theme'; import { hostContext } from '../../utils/theme';
let ids = 0; let ids = 0;
@ -26,6 +27,8 @@ let ids = 0;
}) })
export class SegmentButton implements ComponentInterface, ButtonInterface { export class SegmentButton implements ComponentInterface, ButtonInterface {
private segmentEl: HTMLIonSegmentElement | null = null; private segmentEl: HTMLIonSegmentElement | null = null;
private nativeEl: HTMLButtonElement | undefined;
private inheritedAttributes: Attributes = {};
@Element() el!: HTMLElement; @Element() el!: HTMLElement;
@ -69,6 +72,12 @@ export class SegmentButton implements ComponentInterface, ButtonInterface {
} }
} }
componentWillLoad() {
this.inheritedAttributes = {
...inheritAttributes(this.el, ['aria-label']),
};
}
private get hasLabel() { private get hasLabel() {
return !!this.el.querySelector('ion-label'); return !!this.el.querySelector('ion-label');
} }
@ -87,20 +96,26 @@ export class SegmentButton implements ComponentInterface, ButtonInterface {
} }
}; };
private get tabIndex() { /**
return this.checked && !this.disabled ? 0 : -1; * @internal
* Focuses the native <button> element
* inside of ion-segment-button.
*/
@Method()
async setFocus() {
const { nativeEl } = this;
if (nativeEl !== undefined) {
nativeEl.focus();
}
} }
render() { render() {
const { checked, type, disabled, hasIcon, hasLabel, layout, segmentEl, tabIndex } = this; const { checked, type, disabled, hasIcon, hasLabel, layout, segmentEl } = this;
const mode = getIonMode(this); const mode = getIonMode(this);
const hasSegmentColor = () => segmentEl?.color !== undefined; const hasSegmentColor = () => segmentEl?.color !== undefined;
return ( return (
<Host <Host
role="tab"
aria-selected={checked ? 'true' : 'false'}
aria-disabled={disabled ? 'true' : null}
tabIndex={tabIndex}
class={{ class={{
[mode]: true, [mode]: true,
'in-toolbar': hostContext('ion-toolbar', this.el), 'in-toolbar': hostContext('ion-toolbar', this.el),
@ -119,7 +134,16 @@ export class SegmentButton implements ComponentInterface, ButtonInterface {
'ion-focusable': true, 'ion-focusable': true,
}} }}
> >
<button type={type} tabIndex={-1} class="button-native" part="native" disabled={disabled}> <button
aria-selected={checked ? 'true' : 'false'}
role="tab"
ref={(el) => (this.nativeEl = el)}
type={type}
class="button-native"
part="native"
disabled={disabled}
{...this.inheritedAttributes}
>
<span class="button-inner"> <span class="button-inner">
<slot></slot> <slot></slot>
</span> </span>

View File

@ -165,7 +165,6 @@ export class Segment implements ComponentInterface {
async componentDidLoad() { async componentDidLoad() {
this.setCheckedClasses(); this.setCheckedClasses();
this.ensureFocusable();
this.gesture = (await import('../../utils/gesture')).createGesture({ this.gesture = (await import('../../utils/gesture')).createGesture({
el: this.el, el: this.el,
@ -519,19 +518,7 @@ export class Segment implements ComponentInterface {
const previous = this.checked || current; const previous = this.checked || current;
this.checkButton(previous, current); this.checkButton(previous, current);
} }
current.focus(); current.setFocus();
}
/* By default, focus is delegated to the selected `ion-segment-button`.
* If there is no selected button, focus will instead pass to the first child button.
**/
private ensureFocusable() {
if (this.value !== undefined) {
return;
}
const buttons = this.getButtons();
buttons[0]?.setAttribute('tabindex', '0');
} }
render() { render() {

View File

@ -26,6 +26,20 @@
<ion-label>Shared Links</ion-label> <ion-label>Shared Links</ion-label>
</ion-segment-button> </ion-segment-button>
</ion-segment> </ion-segment>
<br /><br />
<ion-segment aria-label="Tab Options" color="dark" select-on-focus>
<ion-segment-button aria-label="Bookmarks" value="bookmarks">
<ion-icon aria-hidden="true" name="bookmark"></ion-icon>
</ion-segment-button>
<ion-segment-button aria-label="Reading List" value="reading-list">
<ion-icon aria-hidden="true" name="glasses"></ion-icon>
</ion-segment-button>
<ion-segment-button aria-label="Shared Links" value="shared-links">
<ion-icon aria-hidden="true" name="link"></ion-icon>
</ion-segment-button>
</ion-segment>
</ion-content> </ion-content>
</ion-app> </ion-app>
</body> </body>

View File

@ -1,12 +1,12 @@
"native" refers to this sample: https://w3c.github.io/aria-practices/examples/tabs/tabs-2/tabs.html "native" refers to this sample: https://www.w3.org/WAI/ARIA/apg/example-index/tabs/tabs-manual
### Tabbing to Segment Button ### Tabbing to Segment Button
| | native | Ionic | | | native | Ionic |
| ------------------------ | ------------------------------------------------ | ------------------------------------------------ | | ------------------------ | ------------------------------------------------ | ------------------------------------------------ |
| VoiceOver macOS - Chrome | BOOKMARKS, tab, 1 of 3, Tab Options, tab group | BOOKMARKS, tab, 1 of 3, Tab Options, tab group | | VoiceOver macOS - Chrome | BOOKMARKS, tab, 1 of 3, Tab Options, tab group | BOOKMARKS, tab, 1 of 1, Tab Options, tab group |
| VoiceOver macOS - Safari | BOOKMARKS, tab, 1 of 3, Tab Options, tab group | BOOKMARKS, tab, 1 of 3, Tab Options, tab group | | VoiceOver macOS - Safari | BOOKMARKS, tab, 1 of 3, Tab Options, tab group | BOOKMARKS, tab, 1 of 3, Tab Options, tab group |
| VoiceOver iOS | Bookmarks, tab | Bookmarks, tab | | VoiceOver iOS | Bookmarks, tab, 1 of 3 | Bookmarks, tab, 1 of 3 |
| Android TalkBack | Bookmarks, tab | Bookmarks, tab | | Android TalkBack | Bookmarks, tab | Bookmarks, tab |
| Windows NVDA | Tab Options, tab control, BOOKMARKS, tab, 1 of 3 | Tab Options, tab control, BOOKMARKS, tab, 1 of 3 | | Windows NVDA | Tab Options, tab control, BOOKMARKS, tab, 1 of 3 | Tab Options, tab control, BOOKMARKS, tab, 1 of 3 |
@ -14,11 +14,12 @@
| | native | Ionic | | | native | Ionic |
| ------------------------ | -------------------------------------------------------- | ------------------------ | | ------------------------ | -------------------------------------------------------- | ------------------------ |
| VoiceOver macOS - Chrome | BOOKMARKS, selected, tab, 1 of 3, Tab Options, tab group | BOOKMARKS, selected, tab, 1 of 3, Tab Options, tab group | | VoiceOver macOS - Chrome | BOOKMARKS, selected, tab, 1 of 3, Tab Options, tab group | BOOKMARKS, selected, tab, 1 of 1, Tab Options, tab group |
| VoiceOver macOS - Safari | BOOKMARKS, selected, tab, 1 of 3, Tab Options, tab group | BOOKMARKS, selected, tab, 1 of 3, Tab Options, tab group | | VoiceOver macOS - Safari | BOOKMARKS, selected, tab, 1 of 3, Tab Options, tab group | BOOKMARKS, selected, tab, 1 of 3, Tab Options, tab group |
| VoiceOver iOS | selected, Bookmarks, tab | selected, Bookmarks, tab | | VoiceOver iOS | selected, Bookmarks, tab, 1 of 3 | selected, Bookmarks, tab, 1 of 3 |
| Android TalkBack | selected | selected | | Android TalkBack | selected | selected |
| Windows NVDA | BOOKMARKS, tab, 1 of 3, selected | BOOKMARKS, tab, 1 of 3, selected | | Windows NVDA | BOOKMARKS, tab, 1 of 3, selected | BOOKMARKS, tab, 1 of 3, selected |
Note: The `aria-label` for tablist is typically only read on the first interaction. Note: The `aria-label` for tablist is typically only read on the first interaction.
VoiceOver + Chrome announces the wrong tab count due to the `role="tab"` being in the Shadow DOM: https://bugs.chromium.org/p/chromium/issues/detail?id=1405462

View File

@ -6,8 +6,7 @@ test.describe('segment: a11y', () => {
test('should not have any axe violations', async ({ page }) => { test('should not have any axe violations', async ({ page }) => {
await page.goto('/src/components/segment/test/a11y'); await page.goto('/src/components/segment/test/a11y');
// TODO(FW-403): Re-enable rule once segment button is updated to avoid nested-interactive const results = await new AxeBuilder({ page }).analyze();
const results = await new AxeBuilder({ page }).disableRules('nested-interactive').analyze();
expect(results.violations).toEqual([]); expect(results.violations).toEqual([]);
}); });