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,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 && (
0 commit comments