import { CSSResultArray , DefaultElementSize , html , nothing , PropertyValues , render , SizedMixin , TemplateResult , } from '@spectrum-web-components/base' ; import { classMap } from '@spectrum-web-components/base/src/directives.js' ; import { property , query , } from '@spectrum-web-components/base/src/decorators.js' ; import pickerStyles from './picker.css.js' ; import chevronStyles from '@spectrum-web-components/icon/src/spectrum-icon-chevron.css.js' ; import { Focusable } from '@spectrum-web-components/shared/src/focusable.js' ; import { reparentChildren } from '@spectrum-web-components/shared/src/reparent-children.js' ; import '@spectrum-web-components/icons-ui/icons/sp-icon-chevron100.js' ; import '@spectrum-web-components/icons-workflow/icons/sp-icon-alert.js' ; import '@spectrum-web-components/menu/sp-menu.js' ; import type { Menu , MenuItem , MenuItemAddedOrUpdatedEvent , MenuItemChildren , MenuItemRemovedEvent , } from '@spectrum-web-components/menu' ; import '@spectrum-web-components/popover/sp-popover.js' ; import { Popover } from '@spectrum-web-components/popover' ; import { openOverlay , OverlayOptions , Placement , TriggerInteractions , } from '@spectrum-web-components/overlay' ; const chevronClass = { s : 'spectrum-UIIcon-ChevronDown75' , m : 'spectrum-UIIcon-ChevronDown100' , l : 'spectrum-UIIcon-ChevronDown200' , xl : 'spectrum-UIIcon-ChevronDown300' , } ; element slot slot fires fires fires export class PickerBase extends SizedMixin ( Focusable ) { private public static openOverlay = async ( target , interaction , content , options ) : Promise < ( ) => void > => { return await openOverlay ( target , interaction , content , options ) ; } ; @ query ( '#button' ) public button ! : HTMLButtonElement ; public get target ( ) : HTMLButtonElement | this { return this . button ; } @ property ( { type : Boolean , reflect : true } ) public disabled = false ; @ property ( { type : Boolean , reflect : true } ) public focused = false ; @ property ( { type : String , reflect : true } ) public icons ? : 'only' | 'none' ; @ property ( { type : Boolean , reflect : true } ) public invalid = false ; @ property ( ) public label ? : string ; @ property ( { type : Boolean , reflect : true } ) public open = false ; @ property ( { type : Boolean , reflect : true } ) public readonly = false ; public selects : undefined | 'single' = 'single' ; public menuItems : MenuItem [ ] = [ ] ; private restoreChildren ? : ( ) => void ; public optionsMenu ! : Menu ; type { "auto" | "auto-start" | "auto-end" | "top" | "bottom" | "right" | "left" | "top-start" | "top-end" | "bottom-start" | "bottom-end" | "right-start" | "right-end" | "left-start" | "left-end" | "none" } attr @ property ( ) public placement : Placement = 'bottom-start' ; @ property ( { type : Boolean , reflect : true } ) public quiet = false ; @ property ( { type : String } ) public value = '' ; @ property ( { attribute : false } ) public selectedItem ? : MenuItem ; private closeOverlay ? : ( ) => void ; private popover ! : Popover ; protected listRole : 'listbox' | 'menu' = 'listbox' ; protected itemRole = 'option' ; public constructor ( ) { super ( ) ; this . onKeydown = this . onKeydown . bind ( this ) ; } public get focusElement ( ) : HTMLElement { if ( this . open ) { return this . optionsMenu ; } return this . button ; } public forceFocusVisible ( ) { this . focused = true ; } public onButtonBlur ( ) { this . focused = false ; ( this . target as HTMLButtonElement ) . removeEventListener ( 'keydown' , this . onKeydown ) ; } protected onButtonClick ( ) { this . toggle ( ) ; } public focus ( options ? ) { super . focus ( options ) ; if ( ! this . disabled && this . focusElement ) { this . focused = this . hasVisibleFocusInTree ( ) ; } } public onHelperFocus ( ) { this . focused = true ; this . button . focus ( ) ; } public onButtonFocus ( ) { ( this . target as HTMLButtonElement ) . addEventListener ( 'keydown' , this . onKeydown ) ; } public handleChange ( event ) { event . stopPropagation ( ) ; const target = event . target as Menu ; const [ selected ] = target . selectedItems ; this . setValueFromItem ( selected , event ) ; } protected onKeydown = ( event ) : void => { this . focused = true ; if ( event . code !== 'ArrowDown' && event . code !== 'ArrowUp' ) { return ; } event . preventDefault ( ) ; this . toggle ( true ) ; } ; public async setValueFromItem ( item , menuChangeEvent ? ) { const oldSelectedItem = this . selectedItem ; const oldValue = this . value ; this . selectedItem = item ; this . value = item . value ; this . open = false ; await this . updateComplete ; const applyDefault = this . dispatchEvent ( new Event ( 'change' , { cancelable : true , } ) ) ; if ( ! applyDefault ) { if ( menuChangeEvent ) { menuChangeEvent . preventDefault ( ) ; } this . selectedItem . selected = false ; if ( oldSelectedItem ) { oldSelectedItem . selected = true ; } this . selectedItem = oldSelectedItem ; this . value = oldValue ; this . open = true ; return ; } if ( oldSelectedItem ) { oldSelectedItem . selected = false ; } item . selected = ! ! this . selects ; } public toggle ( target ? ) { if ( this . readonly ) { return ; } this . open = typeof target !== 'undefined' ? target : ! this . open ; } public close ( ) { if ( this . readonly ) { return ; } this . open = false ; } public overlayCloseCallback = ( ) : void => { this . open = false ; } ; protected onOverlayClosed ( ) { this . close ( ) ; if ( this . restoreChildren ) { this . restoreChildren ( ) ; this . restoreChildren = undefined ; } this . menuStateResolver ( ) ; } private popoverFragment ! : DocumentFragment ; private generatePopover ( deprecatedMenu ) { if ( this . popoverFragment ) return ; this . popoverFragment = document . createDocumentFragment ( ) ; render ( this . renderPopover , this . popoverFragment , { host : this } ) ; this . popover = this . popoverFragment . children [ 0 ] as Popover ; this . optionsMenu = this . popover . children [ 1 ] as Menu ; if ( deprecatedMenu ) { console . warn ( `Deprecation Notice: You no longer need to provide an sp-menu child to ${ this . tagName . toLowerCase ( ) }. Any styling or attributes on the sp-menu will be ignored.` ) ; } } private async openMenu ( ) { let reparentableChildren = [ ] ; const deprecatedMenu = this . querySelector ( 'sp-menu' ) ; this . generatePopover ( deprecatedMenu ) ; if ( deprecatedMenu ) { reparentableChildren = Array . from ( deprecatedMenu . children ) ; } else { reparentableChildren = Array . from ( this . children ) . filter ( ( element ) => { return ! element . hasAttribute ( 'slot' ) ; } ) ; } if ( reparentableChildren . length === 0 ) { this . menuStateResolver ( ) ; return ; } this . restoreChildren = reparentChildren < Element & { focused ? : boolean ; } > ( reparentableChildren , this . optionsMenu , ( ) => { return ( el ) => { if ( typeof el . focused !== 'undefined' ) { el . focused = false ; } } ; } ) ; this . sizePopover ( this . popover ) ; this . addEventListener ( 'sp-opened' , async ( ) => { this . updateMenuItems ( ) ; await Promise . all ( [ this . itemsUpdated , this . optionsMenu . updateComplete , ] ) ; this . menuStateResolver ( ) ; } , { once : true } ) ; this . closeOverlay = await Picker . openOverlay ( this , 'modal' , this . popover , { placement : this . placement , receivesFocus : 'auto' , } ) ; } protected sizePopover ( popover ) { if ( this . quiet ) return ; popover . style . setProperty ( 'min-width' , `${ this . offsetWidth }px` ) ; } private closeMenu ( ) { if ( this . closeOverlay ) { this . closeOverlay ( ) ; delete this . closeOverlay ; } } protected get selectedItemContent ( ) : MenuItemChildren { if ( this . selectedItem ) { return this . selectedItem . itemChildren ; } return { icon : [ ] , content : [ ] } ; } protected renderLabelContent ( content ) { if ( this . value && this . selectedItem ) { return content ; } return html `
            <slot name="label">${ this . label }</slot>
        ` ; } protected get buttonContent ( ) : TemplateResult [ ] { const labelClasses = { 'visually-hidden' : this . icons === 'only' && ! ! this . value , placeholder : ! this . value , } ; return [ html `
                <span id="icon" ?hidden=${ this . icons === 'none' }>
                    ${ this . selectedItemContent . icon }
                </span>
                <span id="label" class=${ classMap ( labelClasses ) }>
                    ${ this . renderLabelContent ( this . selectedItemContent . content ) }
                </span>
                ${ this . invalid ? html `
                          <sp-icon-alert
                              class="validation-icon"
                          ></sp-icon-alert>
                      ` : nothing }
                <sp-icon-chevron100
                    class="picker ${ chevronClass [ this . size as DefaultElementSize ] }"
                ></sp-icon-chevron100>
            ` , ] ; } protected render ( ) { return html `
            <span
                id="focus-helper"
                tabindex="${ this . focused ? '-1' : '0' }"
                @focus=${ this . onHelperFocus }
            ></span>
            <button
                aria-haspopup="true"
                aria-expanded=${ this . open ? 'true' : 'false' }
                aria-labelledby="button icon label"
                id="button"
                class="button"
                @blur=${ this . onButtonBlur }
                @click=${ this . onButtonClick }
                @focus=${ this . onButtonFocus }
                ?disabled=${ this . disabled }
                tabindex="-1"
            >
                ${ this . buttonContent }
            </button>
        ` ; } protected update ( changes ) { if ( this . selects ) { this . selects = 'single' ; } super . update ( changes ) ; } protected get dismissHelper ( ) : TemplateResult { return html `
            <div class="visually-hidden">
                <button
                    tabindex="-1"
                    arial-label="Dismiss"
                    @click=${ this . close }
                ></button>
            </div>
        ` ; } protected get renderPopover ( ) : TemplateResult { return html `
            <sp-popover
                id="popover"
                role="dialog"
                @sp-menu-item-added-or-updated=${ this . updateMenuItems }
                @sp-overlay-closed=${ this . onOverlayClosed }
                .overlayCloseCallback=${ this . overlayCloseCallback }
            >
                ${ this . dismissHelper }
                <sp-menu
                    id="menu"
                    role="${ this . listRole }"
                    @change=${ this . handleChange }
                    .selects=${ this . selects }
                ></sp-menu>
                ${ this . dismissHelper }
            </sp-popover>
        ` ; } private _willUpdateItems = false ; protected itemsUpdated : Promise < void > = Promise . resolve ( ) ; /**
     * Acquire the available MenuItems in the Picker by
     * direct element query or by assuming the list managed
     * by the Menu within the open options overlay.
     */ protected updateMenuItems ( event ? ) { if ( this . open && event ?. type === 'sp-menu-item-removed' ) return ; if ( this . _willUpdateItems ) return ; this . _willUpdateItems = true ; if ( event ?. item === this . selectedItem ) { this . requestUpdate ( ) ; } let resolve = ( ) : void => { return ; } ; this . itemsUpdated = new Promise ( ( res ) => ( resolve = res ) ) ; window . requestAnimationFrame ( async ( ) => { if ( this . open ) { await this . optionsMenu . updateComplete ; this . menuItems = this . optionsMenu . childItems ; } else { this . menuItems = [ ... this . querySelectorAll ( 'sp-menu-item' ) , ] as MenuItem [ ] ; } this . manageSelection ( ) ; resolve ( ) ; this . _willUpdateItems = false ; } ) ; } protected updated ( changedProperties ) { super . updated ( changedProperties ) ; if ( changedProperties . has ( 'value' ) && ! changedProperties . has ( 'selectedItem' ) ) { this . updateMenuItems ( ) ; } if ( changedProperties . has ( 'disabled' ) && this . disabled ) { this . open = false ; } if ( changedProperties . has ( 'open' ) && ( this . open || typeof changedProperties . get ( 'open' ) !== 'undefined' ) ) { this . menuStatePromise = new Promise ( ( res ) => ( this . menuStateResolver = res ) ) ; if ( this . open ) { this . openMenu ( ) ; } else { this . closeMenu ( ) ; } } } protected manageSelection ( ) { let selectedItem ; this . menuItems . forEach ( ( item ) => { if ( this . value === item . value && ! item . disabled ) { selectedItem = item ; } else { item . selected = false ; } } ) ; if ( selectedItem ) { selectedItem . selected = ! ! this . selects ; this . selectedItem = selectedItem ; } else { this . value = '' ; this . selectedItem = undefined ; } if ( this . open ) { this . optionsMenu . updateComplete . then ( ( ) => { this . optionsMenu . updateSelectedItemIndex ( ) ; } ) ; } } private menuStatePromise = Promise . resolve ( ) ; private menuStateResolver ! : ( ) => void ; protected async getUpdateComplete ( ) { const complete = ( await super . getUpdateComplete ( ) ) as boolean ; await this . menuStatePromise ; await this . itemsUpdated ; return complete ; } public connectedCallback ( ) { this . updateMenuItems ( ) ; this . addEventListener ( 'sp-menu-item-added-or-updated' , this . updateMenuItems ) ; this . addEventListener ( 'sp-menu-item-removed' , this . updateMenuItems ) ; super . connectedCallback ( ) ; } public disconnectedCallback ( ) { this . open = false ; super . disconnectedCallback ( ) ; } } export class Picker extends PickerBase { public static get styles ( ) : CSSResultArray { return [ pickerStyles , chevronStyles ] ; } protected onKeydown = ( event ) : void => { const { code } = event ; this . focused = true ; if ( ! code . startsWith ( 'Arrow' ) || this . readonly ) { return ; } event . preventDefault ( ) ; if ( code === 'ArrowUp' || code === 'ArrowDown' ) { this . toggle ( true ) ; return ; } const selectedIndex = this . selectedItem ? this . menuItems . indexOf ( this . selectedItem ) : - 1 ; const nextOffset = ! this . value || code === 'ArrowRight' ? 1 : - 1 ; let nextIndex = selectedIndex + nextOffset ; while ( this . menuItems [ nextIndex ] && this . menuItems [ nextIndex ] . disabled ) { nextIndex += nextOffset ; } if ( ! this . menuItems [ nextIndex ] || this . menuItems [ nextIndex ] . disabled ) { return ; } if ( ! this . value || nextIndex !== selectedIndex ) { this . setValueFromItem ( this . menuItems [ nextIndex ] ) ; } } ; }