import * as React from "react" ; import classnames from "classnames" ; import "./Listbox.scss" ; import { Guid } from "@itwin/core-bentley" ; import { SpecialKey } from "@itwin/appui-abstract" ; /** Ideas borrowed from  https://reacttraining.com/reach-ui/listbox */ alpha export type ListboxValue = string ; alpha export interface ListboxProps extends React . DetailedHTMLProps < React . HTMLAttributes < HTMLUListElement > , HTMLUListElement > { id ? : string ; selectedValue ? : ListboxValue ; ariaLabel ? : any ; ariaLabelledBy ? : any ; onListboxValueChange ? : ( ( newValue : ListboxValue , isControlOrCommandPressed ? : boolean ) => void ) ; } alpha export interface ListboxItemProps extends React . DetailedHTMLProps < React . LiHTMLAttributes < HTMLLIElement > , HTMLLIElement > { /** The unique item's value. */ value : ListboxValue ; /** set if item is disabled. */ disabled ? : boolean ; } alpha export interface ListboxContextProps { listboxId ? : string ; listboxValue ? : ListboxValue ; focusValue ? : ListboxValue ; onListboxValueChange : ( ( newValue : ListboxValue , isControlOrCommandPressed ? : boolean ) => void ) ; listboxRef ? : React . RefObject < HTMLUListElement > ; } alpha export const ListboxContext = React . createContext < ListboxContextProps > ( { onListboxValueChange : ( _newValue ) => { } } ) ; function makeId ( ... args ) { return args . filter ( ( val ) => val != null ) . join ( "--" ) ; } function getOptionValueArray ( childNodes ) { return React . Children . toArray ( childNodes ) . filter ( ( node ) => React . isValidElement ( node ) && node . props . value ) . map ( ( optionNode ) => ( ( optionNode as React . ReactElement ) . props as ListboxItemProps ) ) ; } function processKeyboardNavigation ( optionValues , itemIndex , key ) { let keyProcessed = false ; let newIndex = itemIndex >= 0 ? itemIndex : 0 ; if ( key === SpecialKey . ArrowDown || key === SpecialKey . PageDown ) { for ( let i = itemIndex + 1 ; i < optionValues . length ; i ++ ) { if ( ! optionValues [ i ] . disabled ) { newIndex = i ; break ; } } keyProcessed = true ; } else if ( key === SpecialKey . ArrowUp || key === SpecialKey . PageUp ) { for ( let i = itemIndex - 1 ; i >= 0 ; i -- ) { if ( ! optionValues [ i ] . disabled ) { newIndex = i ; break ; } } keyProcessed = true ; } else if ( key === SpecialKey . Home ) { for ( let i = 0 ; i < optionValues . length ; i ++ ) { if ( ! optionValues [ i ] . disabled ) { newIndex = i ; break ; } } keyProcessed = true ; } else if ( key === SpecialKey . End ) { for ( let i = optionValues . length - 1 ; i >= 0 ; i -- ) { if ( ! optionValues [ i ] . disabled ) { newIndex = i ; break ; } } keyProcessed = true ; } return [ newIndex , keyProcessed ] ; } alpha export function Listbox ( props ) { const { ariaLabel , ariaLabelledBy , id , children , selectedValue , className , onListboxValueChange , onKeyPress , ... otherProps } = props ; const listRef = React . useRef < HTMLUListElement > ( null ) ; const [ listId ] = React . useState ( ( ) => { return id ?? Guid . createValue ( ) ; } ) ; const optionValues = React . useMemo ( ( ) => getOptionValueArray ( children ) , [ children ] ) ; const classes = React . useMemo ( ( ) => classnames ( "core-listbox" , className ) , [ className ] ) ; const [ currentValue , setCurrentValue ] = React . useState < ListboxValue | undefined > ( selectedValue ) ; const [ focusValue , setFocusValue ] = React . useState < ListboxValue | undefined > ( currentValue ) ; React . useEffect ( ( ) => { setCurrentValue ( selectedValue ) ; setFocusValue ( selectedValue ) ; } , [ selectedValue ] ) ; const scrollTopRef = React . useRef ( 0 ) ; const handleValueChange = React . useCallback ( ( newValue , isControlOrCommandPressed ? ) => { if ( newValue !== currentValue ) { setCurrentValue ( newValue ) ; setFocusValue ( newValue ) ; if ( onListboxValueChange ) onListboxValueChange ( newValue , isControlOrCommandPressed ) ; } } , [ setCurrentValue , currentValue , onListboxValueChange ] ) ; const focusOption = React . useCallback ( ( itemIndex ) => { if ( itemIndex >= 0 && itemIndex < optionValues . length ) { const newSelection = optionValues [ itemIndex ] ; const listElement = listRef . current as HTMLUListElement ; const optionToFocus = listElement . querySelector ( `li[data-value="${ newSelection . value }"]` ) as HTMLLIElement | null ; if ( optionToFocus && listElement ) { let newScrollTop = listElement . scrollTop ; if ( listElement . scrollHeight > listElement . clientHeight ) { const scrollBottom = listElement . clientHeight + listElement . scrollTop ; const elementBottom = optionToFocus . offsetTop + optionToFocus . offsetHeight ; if ( elementBottom > scrollBottom ) { newScrollTop = elementBottom - listElement . clientHeight ; } else if ( optionToFocus . offsetTop < listElement . scrollTop ) { newScrollTop = optionToFocus . offsetTop ; } scrollTopRef . current = newScrollTop ; } setFocusValue ( newSelection . value ) ; } } } , [ optionValues ] ) ; const handleKeyDown = React . useCallback ( ( event ) => { if ( optionValues . length < 1 ) return ; const itemIndex = ( undefined === focusValue ) ? - 1 : optionValues . findIndex ( ( optionValue ) => ( ( optionValue . value === focusValue ) ) ) ; if ( event . key === SpecialKey . Space ) { event . preventDefault ( ) ; if ( focusValue ) handleValueChange ( focusValue , event . getModifierState ( "Control" ) || event . getModifierState ( "Meta" ) ) ; return ; } else { const [ newItemIndex , keyProcessed ] = processKeyboardNavigation ( optionValues , itemIndex , event . key ) ; if ( keyProcessed ) { event . preventDefault ( ) ; focusOption ( newItemIndex ) ; return ; } } if ( onKeyPress ) onKeyPress ( event ) ; } , [ focusValue , optionValues , focusOption , onKeyPress , handleValueChange ] ) ; const isInitialMount = React . useRef ( true ) ; React . useEffect ( ( ) => { const list = listRef . current as HTMLUListElement ; if ( isInitialMount . current ) { isInitialMount . current = false ; if ( undefined !== focusValue ) { const itemIndex = optionValues . findIndex ( ( optionValue ) => ( optionValue . value === focusValue ) ) ; focusOption ( itemIndex ) ; } } else { list . scrollTop = scrollTopRef . current ; } } , [ focusValue , focusOption , optionValues ] ) ; const handleOnScroll = React . useCallback ( ( _event ) => { if ( listRef . current ) scrollTopRef . current = listRef . current . scrollTop ; } , [ ] ) ; const handleOnFocus = React . useCallback ( ( _event ) => { if ( ! focusValue || 0 === focusValue . length ) { if ( currentValue ) { setFocusValue ( currentValue ) ; } else { if ( optionValues . length > 0 ) setFocusValue ( optionValues [ 0 ] . value ) ; } } } , [ currentValue , focusValue , optionValues ] ) ; return < ul className = { classes } aria - labelledby = { ariaLabel ? undefined : ariaLabelledBy } aria - label = { ariaLabel } role = "listbox" tabIndex = { 0 } aria - activedescendant = { makeId ( currentValue , listId ) } { ... otherProps } ref = { listRef } id = { listId } onKeyDown = { handleKeyDown } onScroll = { handleOnScroll } data - value = { currentValue } data - focusvalue = { focusValue } onFocus = { handleOnFocus } > < ListboxContext . Provider value = { { listboxValue : currentValue , focusValue , listboxId : listId , onListboxValueChange : handleValueChange , listboxRef : listRef } } > { children } < /ListboxContext.Provider> < /ul>; } alpha export function ListboxItem ( props ) { const { children , value , className , disabled , ... otherProps } = props ; const { listboxValue , focusValue , listboxId , onListboxValueChange , } = React . useContext ( ListboxContext ) ; const hasFocus = focusValue === value ; const classes = React . useMemo ( ( ) => classnames ( "core-listbox-item" , hasFocus && "focused" , className ) , [ className , hasFocus ] ) ; const itemRef = React . useRef < HTMLLIElement > ( null ) ; const isSelected = listboxValue === value ; const handleClick = React . useCallback ( ( event ) => { event . preventDefault ( ) ; const selectedValue = event . currentTarget ?. dataset ?. value ; if ( undefined !== selectedValue ) { onListboxValueChange ( selectedValue , event . ctrlKey ) ; } } , [ onListboxValueChange ] ) ; const getItemId = React . useCallback ( ( ) => { return makeId ( value , listboxId ) ; } , [ listboxId , value ] ) ; return ( < li aria - selected = { isSelected } aria - disabled = { disabled || undefined } role = "option" className = { classes } { ... otherProps } ref = { itemRef } id = { getItemId ( ) } data - value = { value } onClick = { handleClick } > { children } < /li>); }