import React , { useEffect , useLayoutEffect , useRef , useState , useCallback } from 'react' ; import { CSSObject , jsx , keyframes } from '@emotion/core' ; import { GrowthBehavior , useForkRef , styled , useIsRTL , useUniqueId , } from '@workday/canvas-kit-react/common' ; import { space , commonColors , borderRadius } from '@workday/canvas-kit-react/tokens' ; import { MenuItemProps } from '@workday/canvas-kit-preview-react/menu' ; import { Card } from '@workday/canvas-kit-react/card' ; import { IconButton , IconButtonProps } from '@workday/canvas-kit-react/button' ; import { xSmallIcon } from '@workday/canvas-system-icons-web' ; import { TextInputProps } from '@workday/canvas-kit-react/text-input' ; import flatten from 'lodash.flatten' ; import AutocompleteList from './AutocompleteList' ; import Status from './Status' ; export interface ComboBoxMenuItemGroup { header : React . ReactElement < MenuItemProps > ; items : React . ReactElement < MenuItemProps > [ ] ; } export interface ComboboxProps extends GrowthBehavior , React . HTMLAttributes < HTMLElement > { /**
     * The TextInput child of the Combobox.
     */ children : React . ReactElement < TextInputProps > ; /**
     * The initial value of the Combobox.
     */ initialValue ? : string ; default clearButtonVariant ? : IconButtonProps [ 'variant' ] ; default showClearButton ? : boolean ; default clearButtonAriaLabel ? : string ; /**
     * The autocomplete items of the Combobox. This array of menu items is shown under the text input.
     */ autocompleteItems ? : React . ReactElement < MenuItemProps > [ ] | ComboBoxMenuItemGroup [ ] ; /**
     * The function called when the Combobox text input changes.
     */ onChange ? : ( e : React . ChangeEvent < HTMLInputElement > ) => void ; /**
     * The function called when the Combobox text input focuses.
     */ onFocus ? : React . FocusEventHandler ; /**
     * The function called when the Combobox text input blurs.
     */ onBlur ? : React . FocusEventHandler ; /**
     * The id of the form field.
     */ labelId ? : string ; /**
     * The text for screen readers announcing the autocomplete count
     */ getStatusText ? : ( listCount : number ) => string ; } const Container = styled ( 'div' ) < Pick < ComboboxProps , 'grow' > > ( { display : 'inline-block' , } , ( { grow } ) => ( { width : grow ? '100%' : 'auto' , } ) ) ; const InputContainer = styled ( 'div' ) ( { display : `flex` , alignItems : `center` , position : 'relative' , } ) ; const fadeInKeyframes = keyframes ( { '0%' : { opacity : 0 , } , '100%' : { opacity : 1 , } , } ) ; const MenuContainer = styled ( Card ) ( { position : 'absolute' , zIndex : 1 , left : 0 , top : '100%' , borderRadius : borderRadius . m , background : commonColors . background , border : `none` , marginTop : `-${ borderRadius . m }` , width : '100%' , minWidth : 0 , animation : `${ fadeInKeyframes } 200ms ease-out` , maxHeight : 200 , } ) ; const ResetButton = styled ( IconButton ) < { shouldShow : boolean ; } > ( { width : space . l , minWidth : space . l , height : space . l , position : 'absolute' , margin : `auto ${ space . xxxs }` , top : 0 , bottom : 0 , right : 0 , padding : 0 , zIndex : 2 , transition : 'opacity 120ms' , } , ( { shouldShow } ) => ( { visibility : shouldShow ? 'visible' : 'hidden' , opacity : shouldShow ? 1 : 0 , } ) ) ; export const listBoxIdPart = `listbox` ; const optionIdPart = `option` ; export const getOptionId = ( baseId ? , index ? ) => `${ baseId }-${ optionIdPart }-${ index }` ; export const getTextFromElement = ( children ? ) => { let text = '' ; React . Children . map ( children , child => { if ( child == null || typeof child === 'boolean' || child === { } ) { text += '' ; } else if ( typeof child === 'string' || typeof child === 'number' ) { text += child . toString ( ) ; } else if ( Array . isArray ( child ) ) { text += getTextFromElement ( child ) ; } else if ( 'props' in child ) { text += getTextFromElement ( child . props . children ) ; } } ) ; return text ; } ; const buildStatusString = ( listCount ) : string => { return `There ${ listCount === 1 ? 'is' : 'are' } ${ listCount } suggestion${ listCount === 1 ? '' : 's' }.` ; } ; const isValidSingleChild = ( child ) => { return React . isValidElement ( child ) && React . Children . only ( child ) ; } ; const Combobox = ( { autocompleteItems , children , grow , initialValue , onChange , onFocus , onBlur , showClearButton , clearButtonVariant = 'plain' , clearButtonAriaLabel = `Reset Search Input` , labelId , getStatusText = buildStatusString , id , ... elemProps } ) => { const [ isOpened , setIsOpened ] = useState ( false ) ; const [ value , _setValue ] = useState ( '' ) ; const [ showingAutocomplete , setShowingAutocomplete ] = useState ( false ) ; const [ selectedAutocompleteIndex , setSelectedAutocompleteIndex ] = useState < number | null > ( null ) ; const [ interactiveAutocompleteItems , setInteractiveAutocompleteItems ] = useState < React . ReactElement < MenuItemProps > [ ] > ( [ ] ) ; const [ announcementText , setAnnouncementText ] = useState ( '' ) ; const inputRef = useRef ( null ) ; const elementRef = useForkRef ( ( children as React . ReactElement < TextInputProps > & { ref : React . Ref < HTMLInputElement > ; } ) . ref , inputRef ) ; const comboboxRef = useRef ( null ) ; const randomComponentId = useUniqueId ( ) ; const randomLabelId = useUniqueId ( ) ; const componentId = id || randomComponentId ; const formLabelId = labelId || randomLabelId ; const [ showGroupText , setShowGroupText ] = useState ( false ) ; useLayoutEffect ( ( ) => { const shouldShow = interactiveAutocompleteItems . length > 0 && isOpened ; setShowingAutocomplete ( shouldShow ) ; if ( shouldShow ) { setAnnouncementText ( getStatusText ( interactiveAutocompleteItems . length ) ) ; } } , [ getStatusText , interactiveAutocompleteItems , isOpened ] ) ; const isRTL = useIsRTL ( ) ; const setInputValue = useCallback ( ( newValue ) => { _setValue ( newValue ) ; const inputDomElement = inputRef . current ; if ( inputDomElement ) { const nativeInputValue = Object . getOwnPropertyDescriptor ( Object . getPrototypeOf ( inputDomElement ) , 'value' ) ; if ( nativeInputValue && nativeInputValue . set ) { nativeInputValue . set . call ( inputDomElement , newValue ) ; } let event ; if ( typeof Event === 'function' ) { event = new Event ( 'input' , { bubbles : true } ) ; } else { event = document . createEvent ( 'Event' ) ; event . initEvent ( 'input' , true , true ) ; } inputDomElement . dispatchEvent ( event ) ; } } , [ inputRef ] ) ; useEffect ( ( ) => { if ( initialValue !== null && initialValue !== undefined ) { setInputValue ( initialValue ) ; } } , [ initialValue , setInputValue ] ) ; useEffect ( ( ) => { const getInteractiveAutocompleteItems = ( ) : React . ReactElement < MenuItemProps > [ ] => { if ( autocompleteItems && autocompleteItems . length && autocompleteItems [ 0 ] . hasOwnProperty ( 'header' ) ) { return flatten ( ( autocompleteItems as ComboBoxMenuItemGroup [ ] ) . map ( group => group . items ) ) ; } return ( autocompleteItems as React . ReactElement < MenuItemProps > [ ] ) || [ ] ; } ; setInteractiveAutocompleteItems ( getInteractiveAutocompleteItems ( ) ) ; } , [ autocompleteItems ] ) ; const handleAutocompleteClick = ( event , menuItemProps ) : void => { if ( menuItemProps . isDisabled ) { return ; } setShowingAutocomplete ( false ) ; setIsOpened ( false ) ; setInputValue ( getTextFromElement ( menuItemProps . children ) ) ; if ( menuItemProps . onClick ) { menuItemProps . onClick ( event as React . MouseEvent ) ; } } ; const focusInput = ( ) => { if ( inputRef . current ) { inputRef . current . focus ( ) ; } } ; const handleClick = ( event ) => { if ( ! showingAutocomplete ) { setShowingAutocomplete ( true ) ; } } ; const handleFocus = ( event ) => { setIsOpened ( true ) ; if ( onFocus ) { onFocus ( event ) ; } } ; const handleBlur = ( event ) => { if ( comboboxRef . current ) { let target = event . relatedTarget ; if ( target === null ) { target = document . activeElement ; } if ( target && comboboxRef . current . contains ( target as Element ) ) { return ; } } setIsOpened ( false ) ; if ( onBlur ) { onBlur ( event ) ; } } ; const resetSearchInput = ( ) : void => { setInputValue ( '' ) ; focusInput ( ) ; } ; const getGroupIndex = ( itemIndex ) => { if ( itemIndex != null && autocompleteItems && autocompleteItems . length && autocompleteItems [ 0 ] . hasOwnProperty ( 'header' ) ) { let count = 0 ; return ( autocompleteItems as ComboBoxMenuItemGroup [ ] ) . findIndex ( groups => { count += groups . items . length ; return count > itemIndex ; } ) ; } else { return - 1 ; } } ; const handleKeyboardShortcuts = ( event ) : void => { if ( event . ctrlKey || event . altKey || event . metaKey || ! interactiveAutocompleteItems . length ) { return ; } const autoCompleteItemCount = interactiveAutocompleteItems . length ; const firstItem = 0 ; const lastItem = autoCompleteItemCount - 1 ; let nextIndex = null ; setIsOpened ( true ) ; switch ( event . key ) { case 'ArrowUp' : case 'Up' : const upIndex = selectedAutocompleteIndex !== null ? selectedAutocompleteIndex - 1 : lastItem ; nextIndex = upIndex < 0 ? lastItem : upIndex ; event . stopPropagation ( ) ; event . preventDefault ( ) ; break ; case 'ArrowDown' : case 'Down' : const downIndex = selectedAutocompleteIndex !== null ? selectedAutocompleteIndex + 1 : firstItem ; nextIndex = downIndex >= autoCompleteItemCount ? firstItem : downIndex ; event . stopPropagation ( ) ; event . preventDefault ( ) ; break ; case 'Escape' : case 'Esc' : resetSearchInput ( ) ; break ; case 'Enter' : if ( selectedAutocompleteIndex != null ) { const item = interactiveAutocompleteItems [ selectedAutocompleteIndex ] ; handleAutocompleteClick ( event , item . props ) ; if ( item . props . isDisabled ) { nextIndex = selectedAutocompleteIndex ; } event . stopPropagation ( ) ; event . preventDefault ( ) ; } break ; default : } const lastGroupIndex = getGroupIndex ( selectedAutocompleteIndex ) ; const nextGroupIndex = getGroupIndex ( nextIndex ) ; setShowGroupText ( lastGroupIndex !== nextGroupIndex ) ; setSelectedAutocompleteIndex ( nextIndex ) ; } ; const handleSearchInputChange = ( event ) : void => { if ( onChange ) { onChange ( event ) ; } _setValue ( event . target . value ) ; } ; const renderChildren = ( inputElement ) : React . ReactNode => { let cssOverride = { zIndex : 2 } ; if ( showClearButton ) { const paddingDirection = isRTL ? 'paddingLeft' : 'paddingRight' ; cssOverride = { ... cssOverride , [ paddingDirection ] : space . xl , } ; } const newTextInputProps = { type : 'text' , id : componentId , grow : grow , value : value , ref : elementRef , 'aria-autocomplete' : 'list' , 'aria-activedescendant' : selectedAutocompleteIndex !== null ? getOptionId ( componentId , selectedAutocompleteIndex ) : undefined , onClick : handleClick , onChange : handleSearchInputChange , onKeyDown : handleKeyboardShortcuts , onFocus : handleFocus , onBlur : handleBlur , css : cssOverride , role : 'combobox' , 'aria-owns' : showingAutocomplete ? `${ componentId }-${ listBoxIdPart }` : undefined , 'aria-haspopup' : true , 'aria-expanded' : showingAutocomplete , } ; const cloneElement = ( element , props ) => jsx ( element . type , { ... element . props , ... props , } ) ; return cloneElement ( inputElement , { ... inputElement . props , ... newTextInputProps } ) ; } ; return ( < Container grow = { grow } { ... elemProps } > < InputContainer ref = { comboboxRef } > { isValidSingleChild ( children ) && React . Children . map ( children , renderChildren ) } { showClearButton && ( < ResetButton shouldShow = { ! ! value } aria - label = { clearButtonAriaLabel } icon = { xSmallIcon } variant = { clearButtonVariant } toggled = { undefined } onClick = { resetSearchInput } onBlur = { handleBlur } type = "button" / > ) } { showingAutocomplete && autocompleteItems && ( < MenuContainer padding = { space . zero } depth = { 1 } > < Card . Body > < AutocompleteList comboboxId = { componentId } autocompleteItems = { autocompleteItems } selectedIndex = { selectedAutocompleteIndex } handleAutocompleteClick = { handleAutocompleteClick } labelId = { formLabelId } showGroupText = { showGroupText } / > < /Card.Body> < /MenuContainer>)} < /InputContainer> < Status announcementText = { announcementText } / > < /Container>); } ; export default Combobox ;