diff --git a/core/src/components/action-sheet/action-sheet-interface.ts b/core/src/components/action-sheet/action-sheet-interface.ts index f9cc06cd32f..e8d199796fd 100644 --- a/core/src/components/action-sheet/action-sheet-interface.ts +++ b/core/src/components/action-sheet/action-sheet-interface.ts @@ -19,7 +19,7 @@ export interface ActionSheetOptions { export interface ActionSheetButton { text?: string; - role?: LiteralUnion<'cancel' | 'destructive' | 'selected', string>; + role?: LiteralUnion<'cancel' | 'destructive' | 'selected' | 'radio', string>; icon?: string; cssClass?: string | string[]; id?: string; diff --git a/core/src/components/action-sheet/action-sheet.tsx b/core/src/components/action-sheet/action-sheet.tsx index dcd7c03847b..2daae941e2d 100644 --- a/core/src/components/action-sheet/action-sheet.tsx +++ b/core/src/components/action-sheet/action-sheet.tsx @@ -1,5 +1,5 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Watch, Component, Element, Event, Host, Method, Prop, h, readTask } from '@stencil/core'; +import { Watch, Component, Element, Event, Host, Listen, Method, Prop, State, h, readTask } from '@stencil/core'; import type { Gesture } from '@utils/gesture'; import { createButtonActiveGesture } from '@utils/gesture/button-active'; import { raf } from '@utils/helpers'; @@ -46,11 +46,18 @@ export class ActionSheet implements ComponentInterface, OverlayInterface { private wrapperEl?: HTMLElement; private groupEl?: HTMLElement; private gesture?: Gesture; + private hasRadioButtons = false; presented = false; lastFocus?: HTMLElement; animation?: any; + /** + * The ID of the currently active/selected radio button. + * Used for keyboard navigation and ARIA attributes. + */ + @State() activeRadioId?: string; + @Element() el!: HTMLIonActionSheetElement; /** @internal */ @@ -81,6 +88,22 @@ export class ActionSheet implements ComponentInterface, OverlayInterface { * An array of buttons for the action sheet. */ @Prop() buttons: (ActionSheetButton | string)[] = []; + @Watch('buttons') + buttonsChanged() { + const radioButtons = this.getRadioButtons(); + this.hasRadioButtons = radioButtons.length > 0; + + // Initialize activeRadioId when buttons change + if (this.hasRadioButtons) { + const checkedButton = radioButtons.find((b) => b.htmlAttributes?.['aria-checked'] === 'true'); + + if (checkedButton) { + const allButtons = this.getButtons(); + const checkedIndex = allButtons.indexOf(checkedButton); + this.activeRadioId = this.getButtonId(checkedButton, checkedIndex); + } + } + } /** * Additional classes to apply for custom CSS. If multiple classes are @@ -277,12 +300,50 @@ export class ActionSheet implements ComponentInterface, OverlayInterface { return true; } + /** + * Get all buttons regardless of role. + */ private getButtons(): ActionSheetButton[] { return this.buttons.map((b) => { return typeof b === 'string' ? { text: b } : b; }); } + /** + * Get all radio buttons (buttons with role="radio"). + */ + private getRadioButtons(): ActionSheetButton[] { + return this.getButtons().filter((b) => b.role === 'radio' && !isCancel(b.role)); + } + + /** + * Handle radio button selection and update aria-checked state. + * + * @param button The radio button that was selected. + */ + private async selectRadioButton(button: ActionSheetButton) { + const buttonId = this.getButtonId(button); + + // Set the active radio ID (this will trigger a re-render and update aria-checked) + this.activeRadioId = buttonId; + } + + /** + * Get or generate an ID for a button. + * + * @param button The button for which to get the ID. + * @param index Optional index of the button in the buttons array. + * @returns The ID of the button. + */ + private getButtonId(button: ActionSheetButton, index?: number): string { + if (button.id) { + return button.id; + } + const allButtons = this.getButtons(); + const buttonIndex = index !== undefined ? index : allButtons.indexOf(button); + return `action-sheet-button-${this.overlayIndex}-${buttonIndex}`; + } + private onBackdropTap = () => { this.dismiss(undefined, BACKDROP); }; @@ -295,6 +356,89 @@ export class ActionSheet implements ComponentInterface, OverlayInterface { } }; + /** + * When the action sheet has radio buttons, we want to follow the + * keyboard navigation pattern for radio groups: + * - Arrow Down/Right: Move to the next radio button (wrap to first if at end) + * - Arrow Up/Left: Move to the previous radio button (wrap to last if at start) + * - Space/Enter: Select the focused radio button and trigger its handler + */ + @Listen('keydown') + onKeydown(ev: KeyboardEvent) { + // Only handle keyboard navigation if we have radio buttons + if (!this.hasRadioButtons || !this.presented) { + return; + } + + const target = ev.target as HTMLElement; + + // Ignore if the target element is not within the action sheet or not a radio button + if ( + !this.el.contains(target) || + !target.classList.contains('action-sheet-button') || + target.getAttribute('role') !== 'radio' + ) { + return; + } + + // Get all radio button elements and filter out disabled ones + const radios = Array.from(this.el.querySelectorAll('.action-sheet-button[role="radio"]')).filter( + (el) => !(el as HTMLButtonElement).disabled + ) as HTMLButtonElement[]; + + const currentIndex = radios.findIndex((radio) => radio.id === target.id); + if (currentIndex === -1) { + return; + } + + let nextEl: HTMLButtonElement | undefined; + + if (['ArrowDown', 'ArrowRight'].includes(ev.key)) { + ev.preventDefault(); + ev.stopPropagation(); + + nextEl = currentIndex === radios.length - 1 ? radios[0] : radios[currentIndex + 1]; + } else if (['ArrowUp', 'ArrowLeft'].includes(ev.key)) { + ev.preventDefault(); + ev.stopPropagation(); + + nextEl = currentIndex === 0 ? radios[radios.length - 1] : radios[currentIndex - 1]; + } else if (ev.key === ' ' || ev.key === 'Enter') { + ev.preventDefault(); + ev.stopPropagation(); + + const allButtons = this.getButtons(); + const radioButtons = this.getRadioButtons(); + const buttonIndex = radioButtons.findIndex((b) => { + const buttonId = this.getButtonId(b, allButtons.indexOf(b)); + return buttonId === target.id; + }); + + if (buttonIndex !== -1) { + this.selectRadioButton(radioButtons[buttonIndex]); + this.buttonClick(radioButtons[buttonIndex]); + } + + return; + } + + // Focus the next radio button + if (nextEl) { + const allButtons = this.getButtons(); + const radioButtons = this.getRadioButtons(); + + const buttonIndex = radioButtons.findIndex((b) => { + const buttonId = this.getButtonId(b, allButtons.indexOf(b)); + return buttonId === nextEl?.id; + }); + + if (buttonIndex !== -1) { + this.selectRadioButton(radioButtons[buttonIndex]); + nextEl.focus(); + } + } + } + connectedCallback() { prepareOverlay(this.el); this.triggerChanged(); @@ -312,6 +456,8 @@ export class ActionSheet implements ComponentInterface, OverlayInterface { if (!this.htmlAttributes?.id) { setOverlayId(this.el); } + // Initialize activeRadioId for radio buttons + this.buttonsChanged(); } componentDidLoad() { @@ -355,8 +501,82 @@ export class ActionSheet implements ComponentInterface, OverlayInterface { this.triggerChanged(); } + private renderActionSheetButtons(filteredButtons: ActionSheetButton[]) { + const mode = getIonMode(this); + const { activeRadioId } = this; + + return filteredButtons.map((b, index) => { + const isRadio = b.role === 'radio'; + const buttonId = this.getButtonId(b, index); + const radioButtons = this.getRadioButtons(); + const isActiveRadio = isRadio && buttonId === activeRadioId; + const isFirstRadio = isRadio && b === radioButtons[0]; + + // For radio buttons, set tabindex: 0 for the active one, -1 for others + // For non-radio buttons, use default tabindex (undefined, which means 0) + + /** + * For radio buttons, set tabindex based on activeRadioId + * - If the button is the active radio, tabindex is 0 + * - If no radio is active, the first radio button should have tabindex 0 + * - All other radio buttons have tabindex -1 + * For non-radio buttons, use default tabindex (undefined, which means 0) + */ + let tabIndex: number | undefined; + + if (isRadio) { + // Focus on the active radio button + if (isActiveRadio) { + tabIndex = 0; + } else if (!activeRadioId && isFirstRadio) { + // No active radio, first radio gets focus + tabIndex = 0; + } else { + // All other radios are not focusable + tabIndex = -1; + } + } else { + tabIndex = undefined; + } + + // For radio buttons, set aria-checked based on activeRadioId + // Otherwise, use the value from htmlAttributes if provided + const htmlAttrs = { ...b.htmlAttributes }; + if (isRadio) { + htmlAttrs['aria-checked'] = isActiveRadio ? 'true' : 'false'; + } + + return ( + + ); + }); + } + render() { - const { header, htmlAttributes, overlayIndex } = this; + const { header, htmlAttributes, overlayIndex, hasRadioButtons } = this; const mode = getIonMode(this); const allButtons = this.getButtons(); const cancelButton = allButtons.find((b) => b.role === 'cancel'); @@ -388,7 +608,11 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
(this.wrapperEl = el)}>
-
(this.groupEl = el)}> +
(this.groupEl = el)} + role={hasRadioButtons ? 'radiogroup' : undefined} + > {header !== undefined && (
{this.subHeader}
}
)} - {buttons.map((b) => ( - - ))} + {this.renderActionSheetButtons(buttons)}
{cancelButton && ( diff --git a/core/src/components/action-sheet/test/a11y/action-sheet.e2e.ts b/core/src/components/action-sheet/test/a11y/action-sheet.e2e.ts index abe16b55a75..0d6da17d321 100644 --- a/core/src/components/action-sheet/test/a11y/action-sheet.e2e.ts +++ b/core/src/components/action-sheet/test/a11y/action-sheet.e2e.ts @@ -134,3 +134,58 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { }); }); }); + +/** + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('action-sheet: radio buttons'), () => { + test('should render action sheet with radio buttons correctly', async ({ page }) => { + await page.goto(`/src/components/action-sheet/test/a11y`, config); + + const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent'); + const button = page.locator('#radioButtons'); + + await button.click(); + await ionActionSheetDidPresent.next(); + + const actionSheet = page.locator('ion-action-sheet'); + + const radioButtons = actionSheet.locator('.action-sheet-button[role="radio"]'); + await expect(radioButtons).toHaveCount(2); + }); + + test('should navigate radio buttons with keyboard', async ({ page, pageUtils }) => { + await page.goto(`/src/components/action-sheet/test/a11y`, config); + + const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent'); + const button = page.locator('#radioButtons'); + + await button.click(); + await ionActionSheetDidPresent.next(); + + // Focus on the radios + await pageUtils.pressKeys('Tab'); + + // Verify the first focusable radio button is focused + let focusedElement = await page.evaluate(() => document.activeElement?.textContent?.trim()); + expect(focusedElement).toBe('Option 2'); + + // Navigate to the next radio button + await page.keyboard.press('ArrowDown'); + + // Verify the first radio button is focused again (wrap around) + focusedElement = await page.evaluate(() => document.activeElement?.textContent?.trim()); + expect(focusedElement).toBe('Option 1'); + + // Navigate to the next radio button + await page.keyboard.press('ArrowDown'); + + // Navigate to the cancel button + await pageUtils.pressKeys('Tab'); + + focusedElement = await page.evaluate(() => document.activeElement?.textContent?.trim()); + expect(focusedElement).toBe('Cancel'); + }); + }); +}); diff --git a/core/src/components/action-sheet/test/a11y/index.html b/core/src/components/action-sheet/test/a11y/index.html index 8bb0a4ad9d7..a2e1b3b65a7 100644 --- a/core/src/components/action-sheet/test/a11y/index.html +++ b/core/src/components/action-sheet/test/a11y/index.html @@ -27,6 +27,7 @@

Action Sheet - A11y

+ diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index ac2fbb6d89f..39bc5f5c053 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -556,14 +556,18 @@ export class Select implements ComponentInterface { .filter((cls) => cls !== 'hydrated') .join(' '); const optClass = `${OPTION_CLASS} ${copyClasses}`; + const isSelected = isOptionSelected(selectValue, value, this.compareWith); return { - role: isOptionSelected(selectValue, value, this.compareWith) ? 'selected' : '', + role: 'radio', text: option.textContent, cssClass: optClass, handler: () => { this.setValue(value); }, + htmlAttributes: { + 'aria-checked': isSelected ? 'true' : 'false', + }, } as ActionSheetButton; }); diff --git a/core/src/components/select/test/a11y/select.e2e.ts b/core/src/components/select/test/a11y/select.e2e.ts index 063add891dc..bae522faf64 100644 --- a/core/src/components/select/test/a11y/select.e2e.ts +++ b/core/src/components/select/test/a11y/select.e2e.ts @@ -3,7 +3,7 @@ import { expect } from '@playwright/test'; import { configs, test } from '@utils/test/playwright'; configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => { - test.describe(title('textarea: a11y'), () => { + test.describe(title('select: a11y'), () => { test('default layout should not have accessibility violations', async ({ page }) => { await page.setContent( `