11import 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' ;
33import type { Gesture } from '@utils/gesture' ;
44import { createButtonActiveGesture } from '@utils/gesture/button-active' ;
55import { 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 && (
0 commit comments