import { CSSResultArray , html , PropertyValues , SpectrumElement , TemplateResult , } from '@spectrum-web-components/base' ; import { property , query , } from '@spectrum-web-components/base/src/decorators.js' ; import { MenuItem , MenuItemAddedOrUpdatedEvent , MenuItemRemovedEvent , } from './MenuItem.js' ; import menuStyles from './menu.css.js' ; export interface MenuChildItem { menuItem : MenuItem ; managed : boolean ; active : boolean ; focusable : boolean ; focusRoot : Menu ; } type SelectsType = 'none' | 'ignore' | 'inherit' | 'multiple' | 'single' ; type RoleType = 'group' | 'menu' | 'listbox' | 'none' ; function elementIsOrContains ( el , isOrContains ) { return ! ! isOrContains && ( el === isOrContains || el . contains ( isOrContains ) ) ; } element slot fires attr export class Menu extends SpectrumElement { public static get styles ( ) : CSSResultArray { return [ menuStyles ] ; } @ property ( { type : String , reflect : true } ) public label = '' ; @ property ( { type : String , reflect : true } ) public selects : undefined | 'inherit' | 'single' | 'multiple' ; @ property ( { type : String } ) public value = '' ; @ property ( { type : String , attribute : 'value-separator' } ) public valueSeparator = ',' ; @ property ( { attribute : false } ) public selected = [ ] as string [ ] ; @ property ( { attribute : false } ) public selectedItems = [ ] as MenuItem [ ] ; @ query ( 'slot:not([name])' ) public menuSlot ! : HTMLSlotElement ; private childItemSet = new Set < MenuItem > ( ) ; public focusedItemIndex = 0 ; public focusInItemIndex = 0 ; private selectedItemsMap = new Map ( ) as Map < MenuItem , boolean > ; public get childItems ( ) : MenuItem [ ] { if ( ! this . cachedChildItems ) { this . cachedChildItems = this . updateCachedMenuItems ( ) ; } return this . cachedChildItems ; } private cachedChildItems : MenuItem [ ] | undefined ; private updateCachedMenuItems ( ) { this . cachedChildItems = [ ] ; const slotElements = this . menuSlot ? this . menuSlot . assignedElements ( { flatten : true } ) : [ ] ; for ( const slotElement of slotElements ) { const childMenuItems = slotElement instanceof MenuItem ? [ slotElement as MenuItem ] : ( [ ... slotElement . querySelectorAll ( `*` ) ] as MenuItem [ ] ) ; for ( const childMenuItem of childMenuItems ) { if ( this . childItemSet . has ( childMenuItem ) ) { this . cachedChildItems . push ( childMenuItem ) ; } } } return this . cachedChildItems ; } private public get childRole ( ) : string { if ( this . resolvedRole === 'listbox' ) { return 'option' ; } switch ( this . resolvedSelects ) { case 'single' : return 'menuitemradio' ; case 'multiple' : return 'menuitemcheckbox' ; default : return 'menuitem' ; } } protected get ownRole ( ) : string { return 'menu' ; } private resolvedSelects ? : SelectsType ; private resolvedRole ? : RoleType ; param event private onFocusableItemAddedOrUpdated ( event ) { if ( event . item . menuData . focusRoot ) { this . tabIndex = - 1 ; } event . focusRoot = this ; this . addChildItem ( event . item ) ; if ( this . selects === 'inherit' ) { this . resolvedSelects = 'inherit' ; this . resolvedRole = ( event . currentAncestorWithSelects ?. getAttribute ( 'role' ) || this . getAttribute ( 'role' ) || undefined ) as RoleType ; } else if ( this . selects ) { this . resolvedRole = ( this . getAttribute ( 'role' ) || undefined ) as RoleType ; this . resolvedSelects = this . selects ; event . currentAncestorWithSelects = this ; } else { this . resolvedRole = ( this . getAttribute ( 'role' ) || undefined ) as RoleType ; this . resolvedSelects = this . resolvedRole === 'none' ? 'ignore' : 'none' ; } } param event private onSelectableItemAddedOrUpdated ( event ) { const selects = this . resolvedSelects === 'single' || this . resolvedSelects === 'multiple' ; if ( ( selects || ( ! this . selects && this . resolvedSelects !== 'ignore' ) ) && ! event . item . menuData . selectionRoot ) { event . item . setRole ( this . childRole ) ; event . selectionRoot = this ; } } private addChildItem ( item ) { this . childItemSet . add ( item ) ; this . handleItemsChanged ( ) ; } private async removeChildItem ( event ) { this . childItemSet . delete ( event . item ) ; this . cachedChildItems = undefined ; if ( event . item . focused ) { this . handleItemsChanged ( ) ; await this . updateComplete ; this . focus ( ) ; } } public constructor ( ) { super ( ) ; this . addEventListener ( 'sp-menu-item-added-or-updated' , this . onSelectableItemAddedOrUpdated ) ; this . addEventListener ( 'sp-menu-item-added-or-updated' , this . onFocusableItemAddedOrUpdated , { capture : true , } ) ; this . addEventListener ( 'sp-menu-item-removed' , this . removeChildItem ) ; this . addEventListener ( 'click' , this . onClick ) ; this . addEventListener ( 'focusin' , this . handleFocusin ) ; } public focus ( { preventScroll } = { } ) { if ( ! this . childItems . length || this . childItems . every ( ( childItem ) => childItem . disabled ) ) { return ; } if ( this . childItems . some ( ( childItem ) => childItem . menuData . focusRoot !== this ) ) { super . focus ( { preventScroll } ) ; return ; } this . focusMenuItemByOffset ( 0 ) ; super . focus ( { preventScroll } ) ; const selectedItem = this . querySelector ( '[selected]' ) ; if ( selectedItem && ! preventScroll ) { selectedItem . scrollIntoView ( { block : 'nearest' } ) ; } } private onClick ( event ) { if ( event . defaultPrevented ) { return ; } const path = event . composedPath ( ) ; const target = path . find ( ( el ) => { if ( ! ( el instanceof Element ) ) { return false ; } return el . getAttribute ( 'role' ) === this . childRole ; } ) as MenuItem ; if ( target ?. href && target . href . length ) { return ; } if ( target ?. menuData . selectionRoot === this ) { event . preventDefault ( ) ; this . selectOrToggleItem ( target ) ; } else { return ; } this . prepareToCleanUp ( ) ; } public handleFocusin ( event ) { const isOrContainsRelatedTarget = elementIsOrContains ( this , event . relatedTarget as Node ) ; if ( isOrContainsRelatedTarget || this . childItems . some ( ( childItem ) => childItem . menuData . focusRoot !== this ) ) { return ; } const activeElement = ( this . getRootNode ( ) as Document ) . activeElement as MenuItem | Menu ; const selectionRoot = this . childItems [ this . focusedItemIndex ] ?. menuData . selectionRoot || this ; if ( activeElement !== selectionRoot || ! isOrContainsRelatedTarget ) { selectionRoot . focus ( { preventScroll : true } ) ; if ( activeElement && this . focusedItemIndex === 0 ) { const offset = this . childItems . findIndex ( ( childItem ) => childItem === activeElement ) ; if ( offset > 0 ) { this . focusMenuItemByOffset ( offset ) ; } } } this . startListeningToKeyboard ( ) ; } public startListeningToKeyboard ( ) { this . addEventListener ( 'keydown' , this . handleKeydown ) ; this . addEventListener ( 'focusout' , this . handleFocusout ) ; } public handleFocusout ( event ) { if ( elementIsOrContains ( this , event . relatedTarget as Node ) ) { ( event . composedPath ( ) [ 0 ] as MenuItem ) . focused = false ; return ; } this . stopListeningToKeyboard ( ) ; if ( event . target === this && this . childItems . some ( ( childItem ) => childItem . menuData . focusRoot === this ) ) { const focusedItem = this . childItems [ this . focusedItemIndex ] ; if ( focusedItem ) { focusedItem . focused = false ; } } this . removeAttribute ( 'aria-activedescendant' ) ; } public stopListeningToKeyboard ( ) { this . removeEventListener ( 'keydown' , this . handleKeydown ) ; this . removeEventListener ( 'focusout' , this . handleFocusout ) ; } public async selectOrToggleItem ( targetItem ) { const resolvedSelects = this . resolvedSelects ; const oldSelectedItemsMap = new Map ( this . selectedItemsMap ) ; const oldSelected = this . selected . slice ( ) ; const oldSelectedItems = this . selectedItems . slice ( ) ; const oldValue = this . value ; this . childItems [ this . focusedItemIndex ] . focused = false ; this . focusedItemIndex = this . childItems . indexOf ( targetItem ) ; this . forwardFocusVisibleToItem ( targetItem ) ; if ( resolvedSelects === 'multiple' ) { if ( this . selectedItemsMap . has ( targetItem ) ) { this . selectedItemsMap . delete ( targetItem ) ; } else { this . selectedItemsMap . set ( targetItem , true ) ; } const selected = [ ] ; const selectedItems = [ ] ; this . childItemSet . forEach ( ( childItem ) => { if ( childItem . menuData . selectionRoot !== this ) return ; if ( this . selectedItemsMap . has ( childItem ) ) { selected . push ( childItem . value ) ; selectedItems . push ( childItem ) ; } } ) ; this . selected = selected ; this . selectedItems = selectedItems ; this . value = this . selected . join ( this . valueSeparator ) ; } else { this . selectedItemsMap . clear ( ) ; this . selectedItemsMap . set ( targetItem , true ) ; this . value = targetItem . value ; this . selected = [ targetItem . value ] ; this . selectedItems = [ targetItem ] ; } await this . updateComplete ; const applyDefault = this . dispatchEvent ( new Event ( 'change' , { cancelable : true , bubbles : true , composed : true , } ) ) ; if ( ! applyDefault ) { this . selected = oldSelected ; this . selectedItems = oldSelectedItems ; this . selectedItemsMap = oldSelectedItemsMap ; this . value = oldValue ; return ; } if ( resolvedSelects === 'single' ) { for ( const oldItem of oldSelectedItemsMap . keys ( ) ) { if ( oldItem !== targetItem ) { oldItem . selected = false ; } } targetItem . selected = true ; } else if ( resolvedSelects === 'multiple' ) { targetItem . selected = ! targetItem . selected ; } } public handleKeydown ( event ) { const { code } = event ; if ( code === 'Tab' ) { this . prepareToCleanUp ( ) ; return ; } if ( code === 'Space' || code === 'Enter' ) { this . childItems [ this . focusedItemIndex ] ?. click ( ) ; return ; } if ( code !== 'ArrowDown' && code !== 'ArrowUp' ) { return ; } const lastFocusedItem = this . childItems [ this . focusedItemIndex ] ; const direction = code === 'ArrowDown' ? 1 : - 1 ; const itemToFocus = this . focusMenuItemByOffset ( direction ) ; if ( itemToFocus === lastFocusedItem ) { return ; } event . preventDefault ( ) ; itemToFocus . scrollIntoView ( { block : 'nearest' } ) ; } public focusMenuItemByOffset ( offset ) { const step = offset || 1 ; const focusedItem = this . childItems [ this . focusedItemIndex ] ; focusedItem . focused = false ; this . focusedItemIndex = ( this . childItems . length + this . focusedItemIndex + offset ) % this . childItems . length ; let itemToFocus = this . childItems [ this . focusedItemIndex ] ; let availableItems = this . childItems . length ; while ( itemToFocus . disabled && availableItems ) { availableItems -= 1 ; this . focusedItemIndex = ( this . childItems . length + this . focusedItemIndex + step ) % this . childItems . length ; itemToFocus = this . childItems [ this . focusedItemIndex ] ; } if ( ! itemToFocus ?. disabled ) { this . forwardFocusVisibleToItem ( itemToFocus ) ; } return itemToFocus ; } private prepareToCleanUp ( ) { document . addEventListener ( 'focusout' , ( ) => { requestAnimationFrame ( ( ) => { const focusedItem = this . childItems [ this . focusedItemIndex ] ; if ( focusedItem ) { focusedItem . focused = false ; this . updateSelectedItemIndex ( ) ; } } ) ; } , { once : true } ) ; } public updateSelectedItemIndex ( ) { let firstOrFirstSelectedIndex = 0 ; const selectedItemsMap = new Map < MenuItem , boolean > ( ) ; const selected = [ ] ; const selectedItems = [ ] ; let itemIndex = this . childItems . length ; while ( itemIndex ) { itemIndex -= 1 ; const childItem = this . childItems [ itemIndex ] ; if ( childItem . menuData . selectionRoot === this ) { if ( childItem . selected ) { firstOrFirstSelectedIndex = itemIndex ; selectedItemsMap . set ( childItem , true ) ; selected . unshift ( childItem . value ) ; selectedItems . unshift ( childItem ) ; } if ( itemIndex !== firstOrFirstSelectedIndex ) { childItem . focused = false ; } } } selectedItems . map ( ( item , i ) => { if ( i > 0 ) { item . focused = false ; } } ) ; this . selectedItemsMap = selectedItemsMap ; this . selected = selected ; this . selectedItems = selectedItems ; this . value = this . selected . join ( this . valueSeparator ) ; this . focusedItemIndex = firstOrFirstSelectedIndex ; this . focusInItemIndex = firstOrFirstSelectedIndex ; } private _willUpdateItems = false ; private handleItemsChanged ( ) { this . cachedChildItems = undefined ; if ( ! this . _willUpdateItems ) { let resolve = ( ) : void => { return ; } ; this . cacheUpdated = new Promise ( ( res ) => ( resolve = res ) ) ; this . _willUpdateItems = true ; window . requestAnimationFrame ( ( ) => { if ( this . cachedChildItems === undefined ) { this . updateSelectedItemIndex ( ) ; this . updateItemFocus ( ) ; } this . _willUpdateItems = false ; resolve ( ) ; } ) ; } } private updateItemFocus ( ) { if ( this . childItems . length == 0 ) { return ; } const focusInItem = this . childItems [ this . focusInItemIndex ] ; if ( ( this . getRootNode ( ) as Document ) . activeElement === focusInItem . menuData . focusRoot ) { this . forwardFocusVisibleToItem ( focusInItem ) ; } } private forwardFocusVisibleToItem ( item ) { if ( item . menuData . focusRoot !== this ) { return ; } item . focused = this . hasVisibleFocusInTree ( ) ; this . setAttribute ( 'aria-activedescendant' , item . id ) ; if ( item . menuData . selectionRoot && item . menuData . selectionRoot !== this ) { item . menuData . selectionRoot . focus ( ) ; } } public render ( ) { return html `
            <slot></slot>
        ` ; } private _notFirstUpdated = false ; protected firstUpdated ( changed ) { super . firstUpdated ( changed ) ; if ( ! this . hasAttribute ( 'tabindex' ) ) { const role = this . getAttribute ( 'role' ) ; if ( role === 'group' ) { this . tabIndex = - 1 ; } else if ( role !== 'none' ) { this . tabIndex = 0 ; } } const updates = [ new Promise ( ( res ) => requestAnimationFrame ( ( ) => res ( true ) ) ) , ] ; [ ... this . children ] . forEach ( ( item ) => { if ( ( item as MenuItem ) . localName === 'sp-menu-item' ) { updates . push ( ( item as MenuItem ) . updateComplete ) ; } } ) ; this . childItemsUpdated = Promise . all ( updates ) ; } protected updated ( changes ) { super . updated ( changes ) ; if ( changes . has ( 'selects' ) && this . _notFirstUpdated ) { this . selectsChanged ( ) ; } if ( changes . has ( 'label' ) ) { if ( this . label ) { this . setAttribute ( 'aria-label' , this . label ) ; } else { this . removeAttribute ( 'aria-label' ) ; } } this . _notFirstUpdated = true ; } protected selectsChanged ( ) { const updates = [ new Promise ( ( res ) => requestAnimationFrame ( ( ) => res ( true ) ) ) , ] ; this . childItemSet . forEach ( ( childItem ) => { updates . push ( childItem . triggerUpdate ( ) ) ; } ) ; this . childItemsUpdated = Promise . all ( updates ) ; } public connectedCallback ( ) { super . connectedCallback ( ) ; if ( ! this . hasAttribute ( 'role' ) ) { this . setAttribute ( 'role' , this . ownRole ) ; } this . updateComplete . then ( ( ) => this . updateItemFocus ( ) ) ; } protected childItemsUpdated ! : Promise < unknown [ ] > ; protected cacheUpdated = Promise . resolve ( ) ; protected async getUpdateComplete ( ) { const complete = ( await super . getUpdateComplete ( ) ) as boolean ; await this . childItemsUpdated ; await this . cacheUpdated ; return complete ; } }