import { CSSResultArray , html , PropertyValues , SpectrumElement , TemplateResult , } from '@spectrum-web-components/base' ; import { property } from '@spectrum-web-components/base/src/decorators.js' ; import type { ActionButton } from '@spectrum-web-components/action-button' ; import styles from './action-group.css.js' ; const EMPTY_SELECTION = [ ] ; element slot fires export class ActionGroup extends SpectrumElement { public static get styles ( ) : CSSResultArray { return [ styles ] ; } public buttons : ActionButton [ ] = [ ] ; protected _buttonSelector = 'sp-action-button' ; @ property ( { type : Boolean , reflect : true } ) public compact = false ; @ property ( { type : Boolean , reflect : true } ) public emphasized = false ; @ property ( { type : Boolean , reflect : true } ) public justified = false ; @ property ( { type : String } ) public label = '' ; @ property ( { type : Boolean , reflect : true } ) public quiet = false ; @ property ( { type : String } ) public selects : undefined | 'single' | 'multiple' ; @ property ( { type : Boolean , reflect : true } ) public vertical = false ; @ property ( { type : Array } ) public selected : string [ ] = EMPTY_SELECTION ; private dispatchChange ( old ) { const applyDefault = this . dispatchEvent ( new Event ( 'change' , { bubbles : true , composed : true , cancelable : true , } ) ) ; if ( ! applyDefault ) { this . selected = old ; this . buttons . map ( ( button ) => { button . selected = this . selected . includes ( button . value ) ; } ) ; } } private setSelected ( selected ) { if ( selected === this . selected ) return ; const old = this . selected ; this . selected = selected ; this . dispatchChange ( old ) ; } public focus ( options ? ) { if ( ! this . buttons . length ) { return ; } const firstButtonNonDisabled = this . buttons . find ( ( button ) => { if ( this . selected ) { return button . selected ; } return ! button . disabled ; } ) ; if ( firstButtonNonDisabled ) { firstButtonNonDisabled . focus ( options ) ; } } private deselectSelectedButtons ( ) { const selected = [ ... this . querySelectorAll ( '[selected]' ) , ] as ActionButton [ ] ; selected . forEach ( ( el ) => { el . selected = false ; el . tabIndex = - 1 ; el . setAttribute ( 'aria-checked' , 'false' ) ; } ) ; } private handleClick ( event ) { const target = event . target as ActionButton ; if ( typeof target . value === 'undefined' ) { return ; } switch ( this . selects ) { case 'single' : { this . deselectSelectedButtons ( ) ; target . selected = true ; target . tabIndex = 0 ; target . setAttribute ( 'aria-checked' , 'true' ) ; this . setSelected ( [ target . value ] ) ; target . focus ( ) ; break ; } case 'multiple' : { const selected = [ ... this . selected ] ; target . selected = ! target . selected ; target . setAttribute ( 'aria-checked' , target . selected ? 'true' : 'false' ) ; if ( target . selected ) { selected . push ( target . value ) ; } else { selected . splice ( this . selected . indexOf ( target . value ) , 1 ) ; } this . setSelected ( selected ) ; this . buttons . forEach ( ( button ) => { button . tabIndex = - 1 ; } ) ; target . tabIndex = 0 ; break ; } default : break ; } } private handleFocusin = ( ) : void => { this . addEventListener ( 'focusout' , this . handleFocusout ) ; this . addEventListener ( 'keydown' , this . handleKeydown ) ; } ; private handleKeydown = ( event ) : void => { const { code } = event ; if ( ! [ 'ArrowUp' , 'ArrowLeft' , 'ArrowRight' , 'ArrowDown' , 'End' , 'Home' , 'PageUp' , 'PageDown' , ] . includes ( code ) ) { return ; } const activeElement = ( this . getRootNode ( ) as Document ) . activeElement as ActionButton ; if ( ! activeElement ) { return ; } let nextIndex = this . buttons . indexOf ( activeElement ) ; if ( nextIndex === - 1 ) { return ; } const circularIndexedElement = < T extends HTMLElement > ( list , index ) : T => list [ ( list . length + index ) % list . length ] ; const buttonFromDelta = ( delta ) : void => { nextIndex += delta ; while ( circularIndexedElement ( this . buttons , nextIndex ) . disabled ) { nextIndex += delta ; } } ; switch ( code ) { case 'ArrowUp' : buttonFromDelta ( - 1 ) ; break ; case 'ArrowLeft' : buttonFromDelta ( this . isLTR ? - 1 : 1 ) ; break ; case 'ArrowRight' : buttonFromDelta ( this . isLTR ? 1 : - 1 ) ; break ; case 'ArrowDown' : buttonFromDelta ( 1 ) ; break ; case 'End' : nextIndex = this . buttons . length ; buttonFromDelta ( - 1 ) ; break ; case 'Home' : nextIndex = - 1 ; buttonFromDelta ( 1 ) ; break ; case 'PageUp' : case 'PageDown' : default : const tagsSiblings = [ ... ( this . getRootNode ( ) as Document ) . querySelectorAll < ActionGroup > ( 'sp-action-group' ) , ] ; if ( tagsSiblings . length < 2 ) { return ; } event . preventDefault ( ) ; const currentIndex = tagsSiblings . indexOf ( this ) ; const offset = code === 'PageUp' ? - 1 : 1 ; let nextRadioGroupIndex = currentIndex + offset ; let nextRadioGroup = circularIndexedElement ( tagsSiblings , nextRadioGroupIndex ) ; while ( ! nextRadioGroup . buttons . length ) { nextRadioGroupIndex += offset ; nextRadioGroup = circularIndexedElement ( tagsSiblings , nextRadioGroupIndex ) ; } nextRadioGroup . focus ( ) ; return ; } event . preventDefault ( ) ; const nextRadio = circularIndexedElement ( this . buttons , nextIndex ) ; activeElement . tabIndex = - 1 ; nextRadio . tabIndex = 0 ; nextRadio . focus ( ) ; } ; private handleFocusout = ( event ) : void => { const { relatedTarget } = event ; if ( ! relatedTarget || ! this . contains ( relatedTarget as HTMLElement ) ) { const firstButtonNonDisabled = this . buttons . find ( ( button ) => { if ( this . selected . length ) { return button . selected ; } return ! button . disabled ; } ) ; if ( firstButtonNonDisabled ) { firstButtonNonDisabled . tabIndex = 0 ; } } this . removeEventListener ( 'keydown' , this . handleKeydown ) ; this . removeEventListener ( 'focusout' , this . handleFocusout ) ; } ; private async manageSelects ( ) { if ( ! this . buttons . length ) { return ; } const options = this . buttons ; switch ( this . selects ) { case 'single' : { this . setAttribute ( 'role' , 'radiogroup' ) ; const selections = [ ] ; let firstEnabled ; const updates = options . map ( async ( option ) => { await option . updateComplete ; option . setAttribute ( 'role' , 'radio' ) ; option . setAttribute ( 'aria-checked' , option . selected ? 'true' : 'false' ) ; option . tabIndex = option . selected ? 0 : - 1 ; if ( option . selected ) { selections . push ( option ) ; } if ( ! firstEnabled && ! option . disabled ) { firstEnabled = option ; } } ) ; await Promise . all ( updates ) ; if ( selections . length ) { selections [ 0 ] . tabIndex = 0 ; } else if ( firstEnabled ) { firstEnabled . tabIndex = 0 ; } const selected = selections . map ( ( button ) => { return button . value ; } ) ; this . selected = selected || EMPTY_SELECTION ; break ; } case 'multiple' : { this . setAttribute ( 'role' , 'group' ) ; const selection = [ ] ; const selections = [ ] ; const updates = options . map ( async ( option ) => { await option . updateComplete ; option . setAttribute ( 'role' , 'checkbox' ) ; option . setAttribute ( 'aria-checked' , option . selected ? 'true' : 'false' ) ; option . tabIndex = - 1 ; if ( option . selected ) { selection . push ( option . value ) ; selections . push ( option ) ; } } ) ; await Promise . all ( updates ) ; const selected = ! ! selection . length ? selection : EMPTY_SELECTION ; this . selected = selected ; if ( selections . length ) { selections [ 0 ] . tabIndex = 0 ; } else { this . buttons [ 0 ] . tabIndex = 0 ; } break ; } default : if ( this . selected . length ) { const selections = [ ] ; const updates = options . map ( async ( option ) => { await option . updateComplete ; option . setAttribute ( 'aria-checked' , option . selected ? 'true' : 'false' ) ; option . setAttribute ( 'role' , 'button' ) ; option . tabIndex = - 1 ; if ( option . selected ) { selections . push ( option ) ; } } ) ; await Promise . all ( updates ) ; selections [ 0 ] . tabIndex = 0 ; this . selected = selections . map ( ( button ) => { return button . value ; } ) ; } else { this . buttons . forEach ( ( option ) => { option . setAttribute ( 'role' , 'button' ) ; option . tabIndex = - 1 ; } ) ; this . buttons [ 0 ] . tabIndex = 0 ; this . removeAttribute ( 'role' ) ; break ; } } } protected render ( ) { return html `
            <slot role="presentation" @slotchange=${ this . manageButtons }></slot>
        ` ; } protected firstUpdated ( changes ) { super . firstUpdated ( changes ) ; this . addEventListener ( 'click' , this . handleClick ) ; this . addEventListener ( 'focusin' , this . handleFocusin ) ; } protected updated ( changes ) { super . updated ( changes ) ; if ( changes . has ( 'selects' ) ) { this . manageSelects ( ) ; this . manageChildren ( ) ; } if ( ( changes . has ( 'quiet' ) && this . quiet ) || ( changes . has ( 'emphasized' ) && this . emphasized ) ) { this . manageChildren ( ) ; } if ( changes . has ( 'label' ) && ( this . label || typeof changes . get ( 'label' ) !== 'undefined' ) ) { if ( this . label . length ) { this . setAttribute ( 'aria-label' , this . label ) ; } else { this . removeAttribute ( 'aria-label' ) ; } } } private manageChildren ( ) { this . buttons . forEach ( ( button ) => { button . quiet = this . quiet ; button . emphasized = this . emphasized ; button . selected = this . selected . includes ( button . value ) ; } ) ; } private manageButtons = ( ) : void => { const slot = this . shadowRoot . querySelector ( 'slot' ) ; if ( ! slot ) return ; const assignedElements = slot . assignedElements ( { flatten : true } ) ; const buttons = assignedElements . reduce ( ( acc , el ) => { if ( el . matches ( this . _buttonSelector ) ) { acc . push ( el ) ; } else { const buttonDescendents = Array . from ( el . querySelectorAll ( `:scope > ${ this . _buttonSelector }` ) ) ; acc . push ( ... buttonDescendents ) ; } return acc ; } , [ ] ) ; this . buttons = buttons as ActionButton [ ] ; const currentlySelectedButtons = [ ] ; this . buttons . forEach ( ( button ) => { if ( button . selected ) { currentlySelectedButtons . push ( button . value ) ; } } ) ; this . selected = this . selected . concat ( currentlySelectedButtons ) ; this . manageChildren ( ) ; this . manageSelects ( ) ; } ; public connectedCallback ( ) { super . connectedCallback ( ) ; if ( ! this . observer ) { this . observer = new MutationObserver ( this . manageButtons ) ; this . manageButtons ( ) ; } this . observer . observe ( this , { childList : true , subtree : true } ) ; } public disconnectedCallback ( ) { this . observer . disconnect ( ) ; super . disconnectedCallback ( ) ; } private observer ! : MutationObserver ; }