Skip to content

Commit 1c89cf0

Browse files
thetaPCbrandyscarneyShaneK
authored
fix(select, action-sheet): use radio role for options (#30769)
Issue number: 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. --> The screen reader does not announce when an option is selected within the action sheet interface. This is because the action sheet uses standard buttons, which do not support a detectable selected state via native properties or ARIA attributes like `aria-checked` or `aria-selected`, creating an inconsistent user experience across different interface types. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> - Updated the action sheet buttons to accept `role="radio"` - Added keyboard navigation to follow the pattern for radio group - Added test ## 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. --> [Basic](https://ionic-framework-git-fw-6818-ionic1.vercel.app/src/components/select/test/basic/) --------- Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Co-authored-by: Shane <shane@shanessite.net> Co-authored-by: Brandy Smith <brandyscarney@users.noreply.github.com>
1 parent 3129565 commit 1c89cf0

File tree

5 files changed

+327
-21
lines changed

5 files changed

+327
-21
lines changed

core/src/components/action-sheet/action-sheet.tsx

Lines changed: 238 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ComponentInterface, EventEmitter } from '@stencil/core';
2-
import { Watch, Component, Element, Event, Host, Method, Prop, h, readTask } from '@stencil/core';
2+
import { Watch, Component, Element, Event, Host, Listen, Method, Prop, State, h, readTask } from '@stencil/core';
33
import type { Gesture } from '@utils/gesture';
44
import { createButtonActiveGesture } from '@utils/gesture/button-active';
55
import { raf } from '@utils/helpers';
@@ -46,11 +46,18 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
4646
private wrapperEl?: HTMLElement;
4747
private groupEl?: HTMLElement;
4848
private gesture?: Gesture;
49+
private hasRadioButtons = false;
4950

5051
presented = false;
5152
lastFocus?: HTMLElement;
5253
animation?: any;
5354

55+
/**
56+
* The ID of the currently active/selected radio button.
57+
* Used for keyboard navigation and ARIA attributes.
58+
*/
59+
@State() activeRadioId?: string;
60+
5461
@Element() el!: HTMLIonActionSheetElement;
5562

5663
/** @internal */
@@ -81,6 +88,22 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
8188
* An array of buttons for the action sheet.
8289
*/
8390
@Prop() buttons: (ActionSheetButton | string)[] = [];
91+
@Watch('buttons')
92+
buttonsChanged() {
93+
const radioButtons = this.getRadioButtons();
94+
this.hasRadioButtons = radioButtons.length > 0;
95+
96+
// Initialize activeRadioId when buttons change
97+
if (this.hasRadioButtons) {
98+
const checkedButton = radioButtons.find((b) => b.htmlAttributes?.['aria-checked'] === 'true');
99+
100+
if (checkedButton) {
101+
const allButtons = this.getButtons();
102+
const checkedIndex = allButtons.indexOf(checkedButton);
103+
this.activeRadioId = this.getButtonId(checkedButton, checkedIndex);
104+
}
105+
}
106+
}
84107

85108
/**
86109
* Additional classes to apply for custom CSS. If multiple classes are
@@ -277,12 +300,53 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
277300
return true;
278301
}
279302

303+
/**
304+
* Get all buttons regardless of role.
305+
*/
280306
private getButtons(): ActionSheetButton[] {
281307
return this.buttons.map((b) => {
282308
return typeof b === 'string' ? { text: b } : b;
283309
});
284310
}
285311

312+
/**
313+
* Get all radio buttons (buttons with role="radio").
314+
*/
315+
private getRadioButtons(): ActionSheetButton[] {
316+
return this.getButtons().filter((b) => {
317+
const role = b.htmlAttributes?.role;
318+
return role === 'radio' && !isCancel(role);
319+
});
320+
}
321+
322+
/**
323+
* Handle radio button selection and update aria-checked state.
324+
*
325+
* @param button The radio button that was selected.
326+
*/
327+
private selectRadioButton(button: ActionSheetButton) {
328+
const buttonId = this.getButtonId(button);
329+
330+
// Set the active radio ID (this will trigger a re-render and update aria-checked)
331+
this.activeRadioId = buttonId;
332+
}
333+
334+
/**
335+
* Get or generate an ID for a button.
336+
*
337+
* @param button The button for which to get the ID.
338+
* @param index Optional index of the button in the buttons array.
339+
* @returns The ID of the button.
340+
*/
341+
private getButtonId(button: ActionSheetButton, index?: number): string {
342+
if (button.id) {
343+
return button.id;
344+
}
345+
const allButtons = this.getButtons();
346+
const buttonIndex = index !== undefined ? index : allButtons.indexOf(button);
347+
return `action-sheet-button-${this.overlayIndex}-${buttonIndex}`;
348+
}
349+
286350
private onBackdropTap = () => {
287351
this.dismiss(undefined, BACKDROP);
288352
};
@@ -295,6 +359,96 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
295359
}
296360
};
297361

362+
/**
363+
* When the action sheet has radio buttons, we want to follow the
364+
* keyboard navigation pattern for radio groups:
365+
* - Arrow Down/Right: Move to the next radio button (wrap to first if at end)
366+
* - Arrow Up/Left: Move to the previous radio button (wrap to last if at start)
367+
* - Space/Enter: Select the focused radio button and trigger its handler
368+
*/
369+
@Listen('keydown')
370+
onKeydown(ev: KeyboardEvent) {
371+
// Only handle keyboard navigation if we have radio buttons
372+
if (!this.hasRadioButtons || !this.presented) {
373+
return;
374+
}
375+
376+
const target = ev.target as HTMLElement;
377+
378+
// Ignore if the target element is not within the action sheet or not a radio button
379+
if (
380+
!this.el.contains(target) ||
381+
!target.classList.contains('action-sheet-button') ||
382+
target.getAttribute('role') !== 'radio'
383+
) {
384+
return;
385+
}
386+
387+
// Get all radio button elements and filter out disabled ones
388+
const radios = Array.from(this.el.querySelectorAll('.action-sheet-button[role="radio"]')).filter(
389+
(el) => !(el as HTMLButtonElement).disabled
390+
) as HTMLButtonElement[];
391+
const currentIndex = radios.findIndex((radio) => radio.id === target.id);
392+
393+
if (currentIndex === -1) {
394+
return;
395+
}
396+
397+
const allButtons = this.getButtons();
398+
const radioButtons = this.getRadioButtons();
399+
/**
400+
* Build a map of button element IDs to their ActionSheetButton
401+
* config objects.
402+
* This allows us to quickly look up which button config corresponds
403+
* to a DOM element when handling keyboard navigation
404+
* (e.g., whenuser presses Space/Enter or arrow keys).
405+
* The key is the ID that was set on the DOM element during render,
406+
* and the value is the ActionSheetButton config that contains the
407+
* handler and other properties.
408+
*/
409+
const buttonIdMap = new Map<string, ActionSheetButton>();
410+
411+
radioButtons.forEach((b) => {
412+
const allIndex = allButtons.indexOf(b);
413+
const buttonId = this.getButtonId(b, allIndex);
414+
buttonIdMap.set(buttonId, b);
415+
});
416+
417+
let nextEl: HTMLButtonElement | undefined;
418+
419+
if (['ArrowDown', 'ArrowRight'].includes(ev.key)) {
420+
ev.preventDefault();
421+
ev.stopPropagation();
422+
423+
nextEl = currentIndex === radios.length - 1 ? radios[0] : radios[currentIndex + 1];
424+
} else if (['ArrowUp', 'ArrowLeft'].includes(ev.key)) {
425+
ev.preventDefault();
426+
ev.stopPropagation();
427+
428+
nextEl = currentIndex === 0 ? radios[radios.length - 1] : radios[currentIndex - 1];
429+
} else if (ev.key === ' ' || ev.key === 'Enter') {
430+
ev.preventDefault();
431+
ev.stopPropagation();
432+
433+
const button = buttonIdMap.get(target.id);
434+
if (button) {
435+
this.selectRadioButton(button);
436+
this.buttonClick(button);
437+
}
438+
439+
return;
440+
}
441+
442+
// Focus the next radio button
443+
if (nextEl) {
444+
const button = buttonIdMap.get(nextEl.id);
445+
if (button) {
446+
this.selectRadioButton(button);
447+
nextEl.focus();
448+
}
449+
}
450+
}
451+
298452
connectedCallback() {
299453
prepareOverlay(this.el);
300454
this.triggerChanged();
@@ -312,6 +466,8 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
312466
if (!this.htmlAttributes?.id) {
313467
setOverlayId(this.el);
314468
}
469+
// Initialize activeRadioId for radio buttons
470+
this.buttonsChanged();
315471
}
316472

317473
componentDidLoad() {
@@ -355,8 +511,82 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
355511
this.triggerChanged();
356512
}
357513

514+
private renderActionSheetButtons(filteredButtons: ActionSheetButton[]) {
515+
const mode = getIonMode(this);
516+
const { activeRadioId } = this;
517+
518+
return filteredButtons.map((b, index) => {
519+
const isRadio = b.htmlAttributes?.role === 'radio';
520+
const buttonId = this.getButtonId(b, index);
521+
const radioButtons = this.getRadioButtons();
522+
const isActiveRadio = isRadio && buttonId === activeRadioId;
523+
const isFirstRadio = isRadio && b === radioButtons[0];
524+
525+
// For radio buttons, set tabindex: 0 for the active one, -1 for others
526+
// For non-radio buttons, use default tabindex (undefined, which means 0)
527+
528+
/**
529+
* For radio buttons, set tabindex based on activeRadioId
530+
* - If the button is the active radio, tabindex is 0
531+
* - If no radio is active, the first radio button should have tabindex 0
532+
* - All other radio buttons have tabindex -1
533+
* For non-radio buttons, use default tabindex (undefined, which means 0)
534+
*/
535+
let tabIndex: number | undefined;
536+
537+
if (isRadio) {
538+
// Focus on the active radio button
539+
if (isActiveRadio) {
540+
tabIndex = 0;
541+
} else if (!activeRadioId && isFirstRadio) {
542+
// No active radio, first radio gets focus
543+
tabIndex = 0;
544+
} else {
545+
// All other radios are not focusable
546+
tabIndex = -1;
547+
}
548+
} else {
549+
tabIndex = undefined;
550+
}
551+
552+
// For radio buttons, set aria-checked based on activeRadioId
553+
// Otherwise, use the value from htmlAttributes if provided
554+
const htmlAttrs = { ...b.htmlAttributes };
555+
if (isRadio) {
556+
htmlAttrs['aria-checked'] = isActiveRadio ? 'true' : 'false';
557+
}
558+
559+
return (
560+
<button
561+
{...htmlAttrs}
562+
role={isRadio ? 'radio' : undefined}
563+
type="button"
564+
id={buttonId}
565+
class={{
566+
...buttonClass(b),
567+
'action-sheet-selected': isActiveRadio,
568+
}}
569+
onClick={() => {
570+
if (isRadio) {
571+
this.selectRadioButton(b);
572+
}
573+
this.buttonClick(b);
574+
}}
575+
disabled={b.disabled}
576+
tabIndex={tabIndex}
577+
>
578+
<span class="action-sheet-button-inner">
579+
{b.icon && <ion-icon icon={b.icon} aria-hidden="true" lazy={false} class="action-sheet-icon" />}
580+
{b.text}
581+
</span>
582+
{mode === 'md' && <ion-ripple-effect></ion-ripple-effect>}
583+
</button>
584+
);
585+
});
586+
}
587+
358588
render() {
359-
const { header, htmlAttributes, overlayIndex } = this;
589+
const { header, htmlAttributes, overlayIndex, hasRadioButtons } = this;
360590
const mode = getIonMode(this);
361591
const allButtons = this.getButtons();
362592
const cancelButton = allButtons.find((b) => b.role === 'cancel');
@@ -388,7 +618,11 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
388618

389619
<div class="action-sheet-wrapper ion-overlay-wrapper" ref={(el) => (this.wrapperEl = el)}>
390620
<div class="action-sheet-container">
391-
<div class="action-sheet-group" ref={(el) => (this.groupEl = el)}>
621+
<div
622+
class="action-sheet-group"
623+
ref={(el) => (this.groupEl = el)}
624+
role={hasRadioButtons ? 'radiogroup' : undefined}
625+
>
392626
{header !== undefined && (
393627
<div
394628
id={headerID}
@@ -401,22 +635,7 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
401635
{this.subHeader && <div class="action-sheet-sub-title">{this.subHeader}</div>}
402636
</div>
403637
)}
404-
{buttons.map((b) => (
405-
<button
406-
{...b.htmlAttributes}
407-
type="button"
408-
id={b.id}
409-
class={buttonClass(b)}
410-
onClick={() => this.buttonClick(b)}
411-
disabled={b.disabled}
412-
>
413-
<span class="action-sheet-button-inner">
414-
{b.icon && <ion-icon icon={b.icon} aria-hidden="true" lazy={false} class="action-sheet-icon" />}
415-
{b.text}
416-
</span>
417-
{mode === 'md' && <ion-ripple-effect></ion-ripple-effect>}
418-
</button>
419-
))}
638+
{this.renderActionSheetButtons(buttons)}
420639
</div>
421640

422641
{cancelButton && (

core/src/components/action-sheet/test/a11y/action-sheet.e2e.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,58 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
134134
});
135135
});
136136
});
137+
138+
/**
139+
* This behavior does not vary across modes/directions.
140+
*/
141+
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
142+
test.describe(title('action-sheet: radio buttons'), () => {
143+
test('should render action sheet with radio buttons correctly', async ({ page }) => {
144+
await page.goto(`/src/components/action-sheet/test/a11y`, config);
145+
146+
const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent');
147+
const button = page.locator('#radioButtons');
148+
149+
await button.click();
150+
await ionActionSheetDidPresent.next();
151+
152+
const actionSheet = page.locator('ion-action-sheet');
153+
154+
const radioButtons = actionSheet.locator('.action-sheet-button[role="radio"]');
155+
await expect(radioButtons).toHaveCount(2);
156+
});
157+
158+
test('should navigate radio buttons with keyboard', async ({ page, pageUtils }) => {
159+
await page.goto(`/src/components/action-sheet/test/a11y`, config);
160+
161+
const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent');
162+
const button = page.locator('#radioButtons');
163+
164+
await button.click();
165+
await ionActionSheetDidPresent.next();
166+
167+
// Focus on the radios
168+
await pageUtils.pressKeys('Tab');
169+
170+
// Verify the first focusable radio button is focused
171+
let focusedElement = await page.evaluate(() => document.activeElement?.textContent?.trim());
172+
expect(focusedElement).toBe('Option 2');
173+
174+
// Navigate to the next radio button
175+
await page.keyboard.press('ArrowDown');
176+
177+
// Verify the first radio button is focused again (wrap around)
178+
focusedElement = await page.evaluate(() => document.activeElement?.textContent?.trim());
179+
expect(focusedElement).toBe('Option 1');
180+
181+
// Navigate to the next radio button
182+
await page.keyboard.press('ArrowDown');
183+
184+
// Navigate to the cancel button
185+
await pageUtils.pressKeys('Tab');
186+
187+
focusedElement = await page.evaluate(() => document.activeElement?.textContent?.trim());
188+
expect(focusedElement).toBe('Cancel');
189+
});
190+
});
191+
});

0 commit comments

Comments
 (0)