import * as React from 'react' ; import { ErrorType , StyledType , createComponent , useForkRef , generateUniqueId , } from '@workday/canvas-kit-react/common' ; import { menuAnimationDuration } from './SelectMenu' ; import SelectBase , { CoreSelectBaseProps , Option , NormalizedOption } from './SelectBase' ; import { MenuVisibility } from './types' ; import { getCorrectedIndexByValue } from './utils' ; export interface SelectProps extends CoreSelectBaseProps { /**
     * The options of the Select. `options` may be an array of objects or an array of strings.
     *
     * If `options` is an array of objects, each object must adhere to the `Option` interface:
     *
     * * `data: object` (optional)
     * * `disabled: boolean` (optional)
     * * `id: string` (optional, a random `id` will be assigned to the object if one isn't provided)
     * * `label: string` (optional, analogous to the text content of an `<option>`)
     * * `value: string` (required, analogous to the `value` attribute of an `<option>`)
     *
     * If `label` is omitted, the `value` will be used to render the option.
     *
     * The `data` object is carried over to the `option` passed into the `renderOption` function where it may then be used to customize how each option is rendered.
     */ options : ( Option | string ) [ ] ; } interface SelectContainerProps extends SelectProps , StyledType { /**
     * The ref passed from `createComponent` to be forwarded to the underlying button element.
     */ buttonRef ? : React . Ref < HTMLButtonElement > ; } interface SelectContainerState { focusedOptionIndex : number ; menuVisibility : MenuVisibility ; } class SelectContainer extends React . Component < SelectContainerProps , SelectContainerState > { static ErrorType = ErrorType ; state : Readonly < SelectContainerState > = { focusedOptionIndex : 0 , menuVisibility : 'closed' , } ; private forwardedButtonRef : React . Ref < HTMLButtonElement > ; private localButtonRef = React . createRef < HTMLButtonElement > ( ) ; private inputRef = React . createRef < HTMLInputElement > ( ) ; private menuRef = React . createRef < HTMLUListElement > ( ) ; private menuAnimationTimer ! : ReturnType < typeof setTimeout > ; private keysSoFar = '' ; private clearKeysSoFarTimeout = 500 ; private clearKeysSoFarTimer ! : ReturnType < typeof setTimeout > ; private normalizedOptions : NormalizedOption [ ] = [ ] ; private areOptionsDefined = ( ) : boolean => { const { options } = this . props ; if ( ! options || options . length === 0 ) { return false ; } return true ; } ; private setNormalizedOptions = ( ) : void => { const { options } = this . props ; if ( ! this . areOptionsDefined ( ) ) { return ; } this . normalizedOptions = options . map ( option => { let data = { } ; let disabled , id , label , value ; if ( typeof option === 'string' ) { disabled = false ; id = generateUniqueId ( ) ; value = option ; label = option ; } else { data = option . data || data ; disabled = ! ! option . disabled ; id = option . id || generateUniqueId ( ) ; value = option . value ; label = option . label || option . value ; } return { data , disabled , id , label , value , } ; } ) ; } ; private getIndexByStartString = ( startIndex , startString , endIndex = this . normalizedOptions . length , ignoreDisabled = true ) : number => { for ( let i = startIndex ; i < endIndex ; i ++ ) { const label = this . normalizedOptions [ i ] . label . toLowerCase ( ) ; if ( label . indexOf ( startString . toLowerCase ( ) ) === 0 ) { if ( ! ignoreDisabled || ( ignoreDisabled && ! this . normalizedOptions [ i ] . disabled ) ) { return i ; } } } return - 1 ; } ; private focusNextEnabledOption = ( startIndex , direction ) => { if ( direction === 0 ) { return ; } const numOptions = this . normalizedOptions . length ; let currentIndex = startIndex ; while ( currentIndex >= 0 && currentIndex < numOptions && this . normalizedOptions [ currentIndex ] . disabled ) { currentIndex += direction ; } if ( currentIndex >= 0 && currentIndex < numOptions && ! this . normalizedOptions [ currentIndex ] . disabled ) { this . setState ( { focusedOptionIndex : currentIndex } ) ; } } ; private updateStateFromValue = ( ) => { this . setState ( { focusedOptionIndex : getCorrectedIndexByValue ( this . normalizedOptions , this . props . value ) , } ) ; } ; private focusButton = ( ) => { if ( this . localButtonRef . current ) { this . localButtonRef . current . focus ( ) ; } } ; private setMenuAnimationTimeout = ( callback ) => { if ( this . menuAnimationTimer ) { clearTimeout ( this . menuAnimationTimer ) ; } this . menuAnimationTimer = setTimeout ( callback , menuAnimationDuration ) ; } ; private openMenu = ( ) => { this . setState ( { menuVisibility : 'opening' } ) ; this . setMenuAnimationTimeout ( ( ) => { this . setState ( { menuVisibility : 'opened' } ) ; } ) ; } ; private closeMenu = ( ) => { this . setState ( { menuVisibility : 'closing' } ) ; this . setMenuAnimationTimeout ( ( ) => { this . setState ( { focusedOptionIndex : getCorrectedIndexByValue ( this . normalizedOptions , this . props . value ) , menuVisibility : 'closed' , } ) ; } ) ; } ; private toggleMenu = ( open ) : void => { const { menuVisibility } = this . state ; if ( open ) { switch ( menuVisibility ) { case 'closed' : this . setState ( { menuVisibility : 'open' } , this . openMenu ) ; break ; case 'close' : case 'closing' : this . openMenu ( ) ; break ; default : break ; } } else { switch ( menuVisibility ) { case 'opened' : this . setState ( { menuVisibility : 'close' } , this . closeMenu ) ; break ; case 'open' : case 'opening' : this . closeMenu ( ) ; break ; default : break ; } } } ; private fireChangeEvent = ( value ) : void => { if ( this . inputRef && this . inputRef . current ) { const nativeInputValue = Object . getOwnPropertyDescriptor ( Object . getPrototypeOf ( this . inputRef . current ) , 'value' ) ; if ( nativeInputValue && nativeInputValue . set ) { nativeInputValue . set . call ( this . inputRef . current , value ) ; } let event ; if ( typeof Event === 'function' ) { event = new Event ( 'change' , { bubbles : true } ) ; } else { event = document . createEvent ( 'Event' ) ; event . initEvent ( 'change' , true , true ) ; } this . inputRef . current . dispatchEvent ( event ) ; } } ; private handleKeyboardTypeAhead = ( key , numOptions ) => { if ( this . state . menuVisibility === 'closing' ) { return ; } let start = this . keysSoFar . length === 0 ? this . state . focusedOptionIndex + 1 : this . state . focusedOptionIndex ; start = start === numOptions ? 0 : start ; this . keysSoFar += key ; this . startClearKeysSoFarTimer ( ) ; let matchIndex ; matchIndex = this . getIndexByStartString ( start , this . keysSoFar ) ; if ( matchIndex === - 1 ) { matchIndex = this . getIndexByStartString ( 0 , this . keysSoFar , start ) ; } if ( matchIndex > - 1 ) { if ( this . state . menuVisibility === 'closed' ) { this . fireChangeEvent ( this . normalizedOptions [ matchIndex ] . value ) ; } else { this . setState ( { focusedOptionIndex : matchIndex } ) ; } } } ; private startClearKeysSoFarTimer = ( ) => { if ( this . clearKeysSoFarTimer ) { clearTimeout ( this . clearKeysSoFarTimer ) ; } this . clearKeysSoFarTimer = setTimeout ( ( ) => { this . keysSoFar = '' ; } , this . clearKeysSoFarTimeout ) ; } ; constructor ( props ) { super ( props ) ; this . setNormalizedOptions ( ) ; this . forwardedButtonRef = useForkRef ( props . buttonRef , this . localButtonRef ) ; } componentDidMount ( ) { this . updateStateFromValue ( ) ; } componentDidUpdate ( prevProps ) { const { options , value } = this . props ; if ( options !== prevProps . options ) { this . setNormalizedOptions ( ) ; this . updateStateFromValue ( ) ; } if ( value !== prevProps . value ) { this . updateStateFromValue ( ) ; } } componentWillUnmount ( ) { if ( this . menuAnimationTimer ) { clearTimeout ( this . menuAnimationTimer ) ; } if ( this . clearKeysSoFarTimer ) { clearTimeout ( this . clearKeysSoFarTimer ) ; } } handleClick = ( event ) : void => { const { menuVisibility } = this . state ; switch ( menuVisibility ) { case 'close' : case 'closing' : if ( this . menuRef . current ) { this . menuRef . current . focus ( ) ; } this . toggleMenu ( true ) ; break ; case 'closed' : this . toggleMenu ( true ) ; break ; default : this . toggleMenu ( false ) ; break ; } } ; handleOptionSelection = ( index ) : void => { if ( this . normalizedOptions [ index ] . disabled ) { return ; } this . toggleMenu ( false ) ; this . focusButton ( ) ; this . fireChangeEvent ( this . normalizedOptions [ index ] . value ) ; } ; handleMenuCloseOnKeyPress = ( ) : void => { this . handleClose ( ) ; this . focusButton ( ) ; } ; handleClose = ( ) : void => { this . toggleMenu ( false ) ; } ; handleKeyDown = ( event ) : void => { const { options } = this . props ; const numOptions = options . length ; const { focusedOptionIndex , menuVisibility } = this . state ; let isShortcut = false ; if ( event . key . length === 1 && event . key . match ( /\S/ ) ) { isShortcut = true ; this . handleKeyboardTypeAhead ( event . key , numOptions ) ; } else { switch ( event . key ) { case 'ArrowUp' : case 'Up' : case 'ArrowDown' : case 'Down' : isShortcut = true ; if ( menuVisibility === 'closed' || menuVisibility === 'closing' ) { this . toggleMenu ( true ) ; } else { const direction = event . key === 'ArrowUp' || event . key === 'Up' ? - 1 : 1 ; const startIndex = focusedOptionIndex + direction ; this . focusNextEnabledOption ( startIndex , direction ) ; } break ; case 'Home' : case 'End' : isShortcut = true ; const direction = event . key === 'Home' ? 1 : - 1 ; const startIndex = event . key === 'Home' ? 0 : numOptions - 1 ; this . focusNextEnabledOption ( startIndex , direction ) ; break ; case 'Tab' : if ( menuVisibility !== 'closed' ) { isShortcut = true ; this . handleMenuCloseOnKeyPress ( ) ; } break ; case 'Spacebar' : case ' ' : isShortcut = true ; if ( this . keysSoFar !== '' ) { this . handleKeyboardTypeAhead ( ' ' , numOptions ) ; } else if ( menuVisibility === 'closed' || menuVisibility === 'closing' ) { this . toggleMenu ( true ) ; } else { this . handleOptionSelection ( focusedOptionIndex ) ; } break ; case 'Enter' : isShortcut = true ; if ( menuVisibility === 'closed' || menuVisibility === 'closing' ) { isShortcut = false ; } else { this . handleOptionSelection ( focusedOptionIndex ) ; } break ; default : } } if ( isShortcut ) { event . stopPropagation ( ) ; event . preventDefault ( ) ; } } ; public render ( ) { const { value , buttonRef , options , onKeyDown , ... elemProps } = this . props ; const { focusedOptionIndex , menuVisibility } = this . state ; const eventHandlers = this . areOptionsDefined ( ) ? { onClick : this . handleClick , onKeyDown : this . handleKeyDown , onClose : this . handleClose , onOptionSelection : this . handleOptionSelection , } : { } ; return ( < SelectBase forwardedButtonRef = { this . forwardedButtonRef } localButtonRef = { this . localButtonRef } focusedOptionIndex = { focusedOptionIndex } inputRef = { this . inputRef } menuRef = { this . menuRef } menuVisibility = { menuVisibility } options = { this . normalizedOptions } value = { value } { ... eventHandlers } { ... elemProps } />); } } export const Select = createComponent ( 'button' ) ( { displayName : 'Select' , Component : ( props , ref , Element ) => ( < SelectContainer as = { Element } buttonRef = { ref } { ... props } / > ) , subComponents : { ErrorType , } , } ) ; export default Select ;