Skip to content

Commit e1f83b0

Browse files
refactor(action-sheet): accept radio role buttons
Co-authored-by: Brandy Smith <[email protected]>
1 parent 3f3ffae commit e1f83b0

File tree

6 files changed

+315
-58
lines changed

6 files changed

+315
-58
lines changed

core/src/components/action-sheet/action-sheet-interface.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export interface ActionSheetOptions {
1919

2020
export interface ActionSheetButton<T = any> {
2121
text?: string;
22-
role?: LiteralUnion<'cancel' | 'destructive' | 'selected', string>;
22+
role?: LiteralUnion<'cancel' | 'destructive' | 'selected' | 'radio', string>;
2323
icon?: string;
2424
cssClass?: string | string[];
2525
id?: string;

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

Lines changed: 228 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,19 @@ 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+
// Initialize activeRadioId when buttons change
94+
if (this.hasRadioButtons) {
95+
const allButtons = this.getButtons();
96+
const radioButtons = this.getRadioButtons();
97+
const checkedButton = radioButtons.find((b) => b.htmlAttributes?.['aria-checked'] === 'true');
98+
if (checkedButton) {
99+
const checkedIndex = allButtons.indexOf(checkedButton);
100+
this.activeRadioId = this.getButtonId(checkedButton, checkedIndex);
101+
}
102+
}
103+
}
84104

85105
/**
86106
* Additional classes to apply for custom CSS. If multiple classes are
@@ -277,12 +297,50 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
277297
return true;
278298
}
279299

300+
/**
301+
* Get all buttons regardless of role.
302+
*/
280303
private getButtons(): ActionSheetButton[] {
281304
return this.buttons.map((b) => {
282305
return typeof b === 'string' ? { text: b } : b;
283306
});
284307
}
285308

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

356+
/**
357+
* When the action sheet has radio buttons, we want to follow the
358+
* keyboard navigation pattern for radio groups:
359+
* - Arrow Down/Right: Move to the next radio button (wrap to first if at end)
360+
* - Arrow Up/Left: Move to the previous radio button (wrap to last if at start)
361+
* - Space/Enter: Select the focused radio button and trigger its handler
362+
*/
363+
@Listen('keydown')
364+
onKeydown(ev: KeyboardEvent) {
365+
// Only handle keyboard navigation if we have radio buttons
366+
if (!this.hasRadioButtons || !this.presented) {
367+
return;
368+
}
369+
370+
const target = ev.target as HTMLElement;
371+
372+
// Ignore if the target element is not within the action sheet or not a radio button
373+
if (
374+
!this.el.contains(target) ||
375+
!target.classList.contains('action-sheet-button') ||
376+
target.getAttribute('role') !== 'radio'
377+
) {
378+
return;
379+
}
380+
381+
// Get all radio button elements and filter out disabled ones
382+
const radios = Array.from(this.el.querySelectorAll('.action-sheet-button[role="radio"]')).filter(
383+
(el) => !(el as HTMLButtonElement).disabled
384+
) as HTMLButtonElement[];
385+
386+
const currentIndex = radios.findIndex((radio) => radio.id === target.id);
387+
if (currentIndex === -1) {
388+
return;
389+
}
390+
391+
let nextEl: HTMLButtonElement | undefined;
392+
393+
if (['ArrowDown', 'ArrowRight'].includes(ev.key)) {
394+
ev.preventDefault();
395+
ev.stopPropagation();
396+
397+
nextEl = currentIndex === radios.length - 1 ? radios[0] : radios[currentIndex + 1];
398+
} else if (['ArrowUp', 'ArrowLeft'].includes(ev.key)) {
399+
ev.preventDefault();
400+
ev.stopPropagation();
401+
402+
nextEl = currentIndex === 0 ? radios[radios.length - 1] : radios[currentIndex - 1];
403+
} else if (ev.key === ' ' || ev.key === 'Enter') {
404+
ev.preventDefault();
405+
ev.stopPropagation();
406+
407+
const allButtons = this.getButtons();
408+
const radioButtons = this.getRadioButtons();
409+
const buttonIndex = radioButtons.findIndex((b) => {
410+
const buttonId = this.getButtonId(b, allButtons.indexOf(b));
411+
return buttonId === target.id;
412+
});
413+
414+
if (buttonIndex !== -1) {
415+
this.selectRadioButton(radioButtons[buttonIndex]);
416+
this.buttonClick(radioButtons[buttonIndex]);
417+
}
418+
419+
return;
420+
}
421+
422+
// Focus the next radio button
423+
if (nextEl) {
424+
const allButtons = this.getButtons();
425+
const radioButtons = this.getRadioButtons();
426+
427+
const buttonIndex = radioButtons.findIndex((b) => {
428+
const buttonId = this.getButtonId(b, allButtons.indexOf(b));
429+
return buttonId === nextEl?.id;
430+
});
431+
432+
if (buttonIndex !== -1) {
433+
this.selectRadioButton(radioButtons[buttonIndex]);
434+
nextEl.focus();
435+
}
436+
}
437+
}
438+
298439
connectedCallback() {
299440
prepareOverlay(this.el);
300441
this.triggerChanged();
442+
443+
this.hasRadioButtons = this.getButtons().some((b) => b.role === 'radio');
301444
}
302445

303446
disconnectedCallback() {
@@ -312,6 +455,8 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
312455
if (!this.htmlAttributes?.id) {
313456
setOverlayId(this.el);
314457
}
458+
// Initialize activeRadioId for radio buttons
459+
this.buttonsChanged();
315460
}
316461

317462
componentDidLoad() {
@@ -355,8 +500,83 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
355500
this.triggerChanged();
356501
}
357502

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

389609
<div class="action-sheet-wrapper ion-overlay-wrapper" ref={(el) => (this.wrapperEl = el)}>
390610
<div class="action-sheet-container">
391-
<div class="action-sheet-group" ref={(el) => (this.groupEl = el)}>
611+
<div
612+
class="action-sheet-group"
613+
ref={(el) => (this.groupEl = el)}
614+
role={hasRadioButtons ? 'radiogroup' : undefined}
615+
>
392616
{header !== undefined && (
393617
<div
394618
id={headerID}
@@ -401,22 +625,7 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
401625
{this.subHeader && <div class="action-sheet-sub-title">{this.subHeader}</div>}
402626
</div>
403627
)}
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-
))}
628+
{this.renderActionSheetButtons(buttons)}
420629
</div>
421630

422631
{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)