From 76a3d18363a05d62729a23e4f6ebfecf4dcd97c9 Mon Sep 17 00:00:00 2001 From: Minwook Shin <163576506+minwookshin@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:31:02 -0400 Subject: [PATCH] fix(segment): use a single roving tab stop --- core/src/components.d.ts | 5 ++ .../segment-button/segment-button.tsx | 20 +++++ core/src/components/segment/segment.tsx | 31 ++++++- .../segment/test/a11y/segment.e2e.ts | 82 +++++++++++++++++++ 4 files changed, 137 insertions(+), 1 deletion(-) diff --git a/core/src/components.d.ts b/core/src/components.d.ts index cf24c5fcd4f..dbe0f309615 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -2959,6 +2959,10 @@ export namespace Components { * @default true */ "swipeGesture": boolean; + /** + * Update the tab stop when a segment button changes. + */ + "updateTabindex": () => Promise; /** * the value of the segment. */ @@ -2983,6 +2987,7 @@ export namespace Components { * The mode determines which platform styles to use. */ "mode"?: "ios" | "md"; + "setButtonTabindex": (value: number) => Promise; "setFocus": () => Promise; /** * The type of the button. diff --git a/core/src/components/segment-button/segment-button.tsx b/core/src/components/segment-button/segment-button.tsx index 6601efd5b40..ee3f0f322f3 100644 --- a/core/src/components/segment-button/segment-button.tsx +++ b/core/src/components/segment-button/segment-button.tsx @@ -36,6 +36,7 @@ export class SegmentButton implements ComponentInterface, ButtonInterface { @Element() el!: HTMLElement; @State() checked = false; + @State() private buttonTabindex = 0; /** * The `id` of the segment content. @@ -46,6 +47,10 @@ export class SegmentButton implements ComponentInterface, ButtonInterface { * If `true`, the user cannot interact with the segment button. */ @Prop({ mutable: true }) disabled = false; + @Watch('disabled') + protected disabledChanged() { + this.segmentEl?.updateTabindex(); + } /** * Set the layout of the text and icon in the segment. @@ -64,6 +69,7 @@ export class SegmentButton implements ComponentInterface, ButtonInterface { @Watch('value') valueChanged() { this.updateState(); + this.segmentEl?.updateTabindex(); } connectedCallback() { @@ -72,6 +78,7 @@ export class SegmentButton implements ComponentInterface, ButtonInterface { this.updateState(); addEventListener(segmentEl, 'ionSelect', this.updateState); addEventListener(segmentEl, 'ionStyle', this.updateStyle); + segmentEl.updateTabindex(); } } @@ -122,6 +129,18 @@ export class SegmentButton implements ComponentInterface, ButtonInterface { } } + /** + * @internal + */ + @Method() + async setButtonTabindex(value: number) { + this.buttonTabindex = value; + // Keep the native tab stop in sync before the next keyboard event. + if (this.nativeEl) { + this.nativeEl.tabIndex = value; + } + } + private get hasLabel() { return !!this.el.querySelector('ion-label'); } @@ -192,6 +211,7 @@ export class SegmentButton implements ComponentInterface, ButtonInterface { class="button-native" part="native" disabled={disabled} + tabindex={this.buttonTabindex} {...this.inheritedAttributes} > diff --git a/core/src/components/segment/segment.tsx b/core/src/components/segment/segment.tsx index e680f22413b..5192ba220e0 100644 --- a/core/src/components/segment/segment.tsx +++ b/core/src/components/segment/segment.tsx @@ -1,5 +1,5 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Component, Element, Event, Host, Listen, Prop, State, Watch, h, writeTask } from '@stencil/core'; +import { Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h, writeTask } from '@stencil/core'; import type { Gesture, GestureDetail } from '@utils/gesture'; import { raf } from '@utils/helpers'; import { isRTL } from '@utils/rtl'; @@ -24,6 +24,7 @@ import type { SegmentChangeEventDetail, SegmentValue } from './segment-interface }) export class Segment implements ComponentInterface { private gesture?: Gesture; + private focusedButton?: HTMLIonSegmentButtonElement; // Value before the segment is dragged private valueBeforeGesture?: SegmentValue; @@ -117,6 +118,7 @@ export class Segment implements ComponentInterface { * Used by `ion-segment-button` to determine if the button should be checked. */ this.ionSelect.emit({ value }); + this.updateTabindex(); // The scroll listener should handle scrolling the active button into view as needed if (!this.segmentViewEl) { @@ -167,6 +169,31 @@ export class Segment implements ComponentInterface { } } + /** + * Update the tab stop when a segment button changes. + * @internal + */ + @Method() + async updateTabindex() { + const buttons = this.getButtons(); + const enabled = buttons.filter((button) => !button.disabled); + const focused = enabled.find((button) => button === this.focusedButton); + const selected = enabled.find((button) => button.value === this.value); + const focusable = focused ?? selected ?? enabled[0]; + + for (const button of buttons) { + button.setButtonTabindex(button === focusable ? 0 : -1); + } + } + + @Listen('focusin') + @Listen('focusout') + protected focusChanged(ev: FocusEvent) { + const target = ev.type === 'focusin' ? ev.target : ev.relatedTarget; + this.focusedButton = this.getButtons().find((button) => button === target); + this.updateTabindex(); + } + private gestureChanged() { if (this.gesture) { this.gesture.enable(!this.scrollable && !this.disabled && this.swipeGesture); @@ -180,6 +207,7 @@ export class Segment implements ComponentInterface { } disconnectedCallback() { + this.focusedButton = undefined; this.segmentViewEl = null; } @@ -191,6 +219,7 @@ export class Segment implements ComponentInterface { this.segmentViewEl = this.getSegmentView(); this.setCheckedClasses(); + this.updateTabindex(); /** * We need to wait for the buttons to all be rendered diff --git a/core/src/components/segment/test/a11y/segment.e2e.ts b/core/src/components/segment/test/a11y/segment.e2e.ts index 9219f4f8ced..f0f844e4123 100644 --- a/core/src/components/segment/test/a11y/segment.e2e.ts +++ b/core/src/components/segment/test/a11y/segment.e2e.ts @@ -40,6 +40,88 @@ configs().forEach(({ title, config }) => { expect(results.violations).toEqual([]); }); + test('should enter the selected button and leave the segment with Tab', async ({ page, pageUtils }) => { + await page.setContent( + ` + + First + Second + Third + + `, + config + ); + const buttons = page.locator('ion-segment-button'); + await page.locator('#before').focus(); + await pageUtils.pressKeys('Tab'); + await expect(buttons.nth(1)).toBeFocused(); + await pageUtils.pressKeys('Tab'); + await expect(page.locator('#after')).toBeFocused(); + await pageUtils.pressKeys('Shift+Tab'); + await expect(buttons.nth(1)).toBeFocused(); + + // Manual activation moves focus without changing the selected button. + await page.keyboard.press('Home'); + await expect(buttons.nth(0)).toBeFocused(); + await expect(buttons.nth(1).locator('button')).toHaveAttribute('aria-selected', 'true'); + await pageUtils.pressKeys('Tab'); + await expect(page.locator('#after')).toBeFocused(); + await pageUtils.pressKeys('Shift+Tab'); + await expect(buttons.nth(1)).toBeFocused(); + await page.keyboard.press('End'); + await expect(buttons.nth(2)).toBeFocused(); + await pageUtils.pressKeys('Shift+Tab'); + await expect(page.locator('#before')).toBeFocused(); + }); + + test('should keep one enabled tab stop when buttons change', async ({ page }) => { + await page.setContent( + ` + First + Second + Third + `, + config + ); + const tabStop = page.locator('ion-segment-button button[tabindex="0"]'); + await expect(tabStop).toHaveCount(1); + await expect(tabStop).toHaveAccessibleName('Second'); + await page.locator('ion-segment-button[value="second"]').evaluate((button: HTMLIonSegmentButtonElement) => { + button.disabled = true; + }); + await expect(tabStop).toHaveCount(1); + await expect(tabStop).toHaveAccessibleName('Third'); + await page.locator('ion-segment-button[value="first"]').evaluate((button: HTMLIonSegmentButtonElement) => { + button.disabled = false; + }); + await expect(tabStop).toHaveCount(1); + await expect(tabStop).toHaveAccessibleName('First'); + await page.locator('ion-segment').evaluate((segment: HTMLIonSegmentElement) => { + segment.value = 'third'; + }); + await expect(tabStop).toHaveAccessibleName('Third'); + await page.locator('ion-segment-button[value="third"]').evaluate((button) => button.remove()); + await expect(tabStop).toHaveCount(1); + await expect(tabStop).toHaveAccessibleName('First'); + await page.locator('ion-segment').evaluate((segment: HTMLIonSegmentElement) => { + segment.value = 'added'; + const button = document.createElement('ion-segment-button'); + button.value = 'added'; + button.textContent = 'Added'; + segment.append(button); + }); + await expect(tabStop).toHaveCount(1); + await expect(tabStop).toHaveAccessibleName('Added'); + await page.locator('ion-segment-button[value="first"] button').focus(); + await expect(tabStop).toHaveAccessibleName('First'); + await page.locator('ion-segment').evaluate((segment) => { + segment.remove(); + document.body.append(segment); + }); + await expect(tabStop).toHaveCount(1); + await expect(tabStop).toHaveAccessibleName('Added'); + }); + test('segment buttons should be keyboard navigable', async ({ page, pageUtils }) => { const isRTL = config.direction === 'rtl'; const nextKey = isRTL ? 'ArrowLeft' : 'ArrowRight';