import { AfterContentChecked , AfterViewInit , ChangeDetectorRef , Component , ContentChild , ContentChildren , ElementRef , EventEmitter , forwardRef , HostBinding , HostListener , Input , OnDestroy , Output , QueryList , Renderer2 , RendererStyleFlags2 , TemplateRef , ViewChild , ViewChildren , } from '@angular/core' ; import { ControlValueAccessor , NG_VALUE_ACCESSOR } from '@angular/forms' ; import { ButtonComponent } from '../button/button.component' ; import { CardComponent } from '../card/card.component' ; import { ItemComponent } from '../item/item.component' ; import { ListItemTemplateDirective } from '../list/list.directive' ; import { HorizontalDirection , PopoverComponent } from '../popover/popover.component' ; import { OpenState , VerticalDirection } from './dropdown.types' ; import { KeyboardHandlerService } from './keyboard-handler.service' ; @ Component ( { selector : 'kirby-dropdown' , templateUrl : './dropdown.component.html' , styleUrls : [ './dropdown.component.scss' ] , providers : [ { provide : NG_VALUE_ACCESSOR , useExisting : forwardRef ( ( ) => DropdownComponent ) , multi : true , } , ] , } ) export class DropdownComponent implements AfterContentChecked , AfterViewInit , OnDestroy , ControlValueAccessor { static readonly OPEN_DELAY_IN_MS = 100 ; private state = OpenState . closed ; private hasConfiguredSlottedItems = false ; private horizontalDirection = HorizontalDirection . right ; private verticalDirection = VerticalDirection . down ; private _items : string [ ] | any [ ] = [ ] ; get items ( ) : string [ ] | any [ ] { return this . _items ; } @ Input ( ) set items ( value ) { this . _items = value ; this . _value = this . items [ this . selectedIndex ] || null ; } private _selectedIndex : number = - 1 ; get selectedIndex ( ) : number { return this . _selectedIndex ; } @ Input ( ) set selectedIndex ( value ) { if ( this . _selectedIndex != value ) { this . _selectedIndex = value ; this . _value = this . items [ this . selectedIndex ] || null ; } } @ Input ( ) itemTextProperty = 'text' ; @ Input ( ) placeholder = 'Please select:' ; @ Input ( ) set popout ( direction ) { this . horizontalDirection = direction || HorizontalDirection . right ; } get popout ( ) { return this . horizontalDirection ; } @ Input ( ) attentionLevel : '1' | '2' | '3' | '4' = '3' ; readonly attentionLevelOpen = '2' ; @ Input ( ) expand ? : 'block' ; @ Input ( ) disabled = false ; @ HostBinding ( 'attr.disabled' ) get _isDisabled ( ) { return this . disabled ? 'disabled' : null ; } @ HostBinding ( 'class.error' ) @ Input ( ) hasError : boolean ; @ Input ( ) size : 'sm' | 'md' = 'md' ; @ Input ( ) tabindex = 0 ; @ HostBinding ( 'class.with-popover' ) @ Input ( ) usePopover = false ; @ HostBinding ( 'attr.tabindex' ) get _tabindex ( ) { return this . disabled ? - 1 : this . tabindex ; } @ HostBinding ( 'attr.no-blur' ) get _noBlurOnScroll ( ) { return true ; } /**
     * Emitted when an item is selected (tap on mobile, click/keypress on web)
     */ @ Output ( ) change : EventEmitter < string | any > = new EventEmitter < string | any > ( ) ; private _value : string | any = null ; get value ( ) : string | any { return this . _value ; } get selectedText ( ) : string { return this . getTextFromItem ( this . value ) ; } @ HostBinding ( 'class.expand' ) get _isBlockLevel ( ) { return this . expand === 'block' ; } @ HostBinding ( 'attr.role' ) _role = 'listbox' ; @ HostBinding ( 'class.is-opening' ) get _isOpening ( ) : boolean { return this . state === OpenState . opening ; } @ HostBinding ( 'class.is-open' ) get isOpen ( ) : boolean { return this . state === OpenState . open ; } @ HostBinding ( 'class.popout-left' ) get _popoutLeft ( ) { return this . horizontalDirection === HorizontalDirection . left ; } @ HostBinding ( 'class.popout-up' ) get _popoutUp ( ) { return this . verticalDirection === VerticalDirection . up ; } @ ContentChild ( ListItemTemplateDirective , { static : true , read : TemplateRef } ) itemTemplate : TemplateRef < any > ; @ ContentChildren ( ListItemTemplateDirective , { read : ElementRef } ) slottedItems : QueryList < ElementRef < HTMLElement > > ; @ ViewChild ( CardComponent , { read : ElementRef } ) cardElement : ElementRef < HTMLElement > ; @ ViewChild ( PopoverComponent ) popover ? : PopoverComponent ; @ ViewChild ( ButtonComponent , { static : true , read : ElementRef } ) buttonElement : ElementRef < HTMLElement > ; @ ViewChildren ( ItemComponent , { read : ElementRef } ) kirbyItemsDefault : QueryList < ElementRef < HTMLElement > > ; @ ContentChildren ( ItemComponent , { read : ElementRef } ) kirbyItemsSlotted : QueryList < ElementRef < HTMLElement > > ; private itemClickUnlisten : ( ( ) => void ) [ ] = [ ] ; private intersectionObserverRef : IntersectionObserver ; private showDropdownTimeoutId : ReturnType < typeof setTimeout > ; constructor ( private renderer , private elementRef , private changeDetectorRef , private keyboardHandlerService ) { } onToggle ( event ) { event . stopPropagation ( ) ; if ( ! this . isOpen ) { this . elementRef . nativeElement . focus ( ) ; } this . toggle ( ) ; } toggle ( ) { if ( this . disabled ) { return ; } this . isOpen ? this . close ( ) : this . open ( ) ; } onButtonMouseEvent ( event ) { event . preventDefault ( ) ; } ngAfterContentChecked ( ) { if ( ! this . hasConfiguredSlottedItems && this . kirbyItemsSlotted . length ) { this . kirbyItemsSlotted . forEach ( ( kirbyItem , index ) => { this . renderer . setAttribute ( kirbyItem . nativeElement , 'role' , 'option' ) ; const unlisten = this . renderer . listen ( kirbyItem . nativeElement , 'click' , ( ) => { this . onItemSelect ( index ) ; } ) ; this . itemClickUnlisten . push ( unlisten ) ; } ) ; this . hasConfiguredSlottedItems = true ; } } private setPopoverCardStyle ( style , value ) { if ( ! this . usePopover ) return ; this . renderer . setStyle ( this . cardElement . nativeElement , style , value , RendererStyleFlags2 . DashCase ) ; } ngAfterViewInit ( ) { if ( this . usePopover && this . expand === 'block' ) { const { width } = this . elementRef . nativeElement . getBoundingClientRect ( ) ; this . setPopoverCardStyle ( '--kirby-card-width' , `${ width }px` ) ; this . setPopoverCardStyle ( 'max-width' , 'initial' ) ; this . setPopoverCardStyle ( 'min-width' , 'initial' ) ; } this . initializeAlignment ( ) ; } private initializeAlignment ( ) { if ( this . usePopover ) return ; if ( ! this . intersectionObserverRef ) { const options = { rootMargin : '0px' , } ; const callback = ( entries ) => { if ( this . state !== OpenState . opening ) { return ; } clearTimeout ( this . showDropdownTimeoutId ) ; const entry = entries [ 0 ] ; const isVisible = entry . boundingClientRect . width > 0 ; if ( isVisible && entry . intersectionRatio < 1 ) { this . setHorizontalDirection ( entry ) ; this . setVerticalDirection ( entry ) ; } this . showDropdown ( ) ; this . changeDetectorRef . detectChanges ( ) ; } ; this . intersectionObserverRef = new IntersectionObserver ( callback , options ) ; this . intersectionObserverRef . observe ( this . cardElement . nativeElement ) ; } } private setHorizontalDirection ( entry ) { if ( this . horizontalDirection === HorizontalDirection . right ) { if ( entry . boundingClientRect . right > entry . rootBounds . right ) { this . horizontalDirection = HorizontalDirection . left ; } } else { if ( entry . boundingClientRect . left < entry . rootBounds . left ) { this . horizontalDirection = HorizontalDirection . right ; } } } private setVerticalDirection ( entry ) { if ( entry . boundingClientRect . top < 0 ) { this . verticalDirection = VerticalDirection . down ; } if ( entry . boundingClientRect . bottom > entry . rootBounds . bottom ) { const containerOffsetTop = this . elementRef . nativeElement . getBoundingClientRect ( ) . top ; const SPACING = 5 ; if ( containerOffsetTop > entry . target . clientHeight + SPACING ) { this . verticalDirection = VerticalDirection . up ; } } } open ( ) { if ( this . disabled ) { return ; } if ( ! this . isOpen ) { this . state = OpenState . opening ; this . showDropdownTimeoutId = setTimeout ( ( ) => this . showDropdown ( ) , DropdownComponent . OPEN_DELAY_IN_MS ) ; } } private showDropdown ( ) { if ( this . state === OpenState . opening ) { this . state = OpenState . open ; this . popover ?. show ( ) ; this . scrollItemIntoView ( this . selectedIndex ) ; this . changeDetectorRef . markForCheck ( ) ; } } close ( ) { if ( this . disabled ) { return ; } if ( this . isOpen ) { this . state = OpenState . closed ; this . verticalDirection = VerticalDirection . down ; this . popover ?. hide ( ) ; } } onItemSelect ( index ) { this . selectItem ( index ) ; this . close ( ) ; } private _onChange : ( value : any ) => void = ( ) => { } ; private _onTouched = ( ) => { } ; param value writeValue ( value ) { this . _selectItemByValue ( value ) ; } param fn registerOnChange ( fn ) { this . _onChange = fn ; } param fn registerOnTouched ( fn ) { this . _onTouched = fn ; } param isDisabled setDisabledState ? ( isDisabled ) { this . disabled = isDisabled ; } private selectItem ( index ) { if ( index != this . selectedIndex ) { this . selectedIndex = index ; this . change . emit ( this . value ) ; this . _onChange ( this . value ) ; this . scrollItemIntoView ( index ) ; } } private _selectItemByValue ( value ) { this . selectedIndex = this . items . indexOf ( value ) ; } getTextFromItem ( item ) { if ( ! item ) { return null ; } return typeof item === 'string' ? item : item [ this . itemTextProperty ] ; } scrollItemIntoView ( index ) { const kirbyItems = this . kirbyItemsSlotted && this . kirbyItemsSlotted . length ? this . kirbyItemsSlotted : this . kirbyItemsDefault ; if ( kirbyItems && kirbyItems . length ) { const selectedKirbyItem = kirbyItems . toArray ( ) [ index ] ; if ( selectedKirbyItem && selectedKirbyItem . nativeElement ) { const itemElement = selectedKirbyItem . nativeElement ; const scrollContainer = this . cardElement . nativeElement ; const itemTop = itemElement . offsetTop ; const itemBottom = itemElement . offsetTop + itemElement . offsetHeight ; const containerVisibleTop = scrollContainer . scrollTop ; const containerVisibleBottom = scrollContainer . clientHeight + scrollContainer . scrollTop ; if ( itemTop < containerVisibleTop ) { scrollContainer . scrollTop = itemTop ; } else if ( itemBottom > containerVisibleBottom ) { scrollContainer . scrollTop = itemBottom - scrollContainer . clientHeight ; } } } } @ HostListener ( 'keydown.tab' , [ '$event' ] ) _onTab ( event ) { if ( this . isOpen ) { event . preventDefault ( ) ; this . close ( ) ; } } @ HostListener ( 'mousedown' , [ '$event' ] ) _onMouseDown ( event ) { if ( this . disabled ) { event . preventDefault ( ) ; event . stopImmediatePropagation ( ) ; } } @ HostListener ( 'focus' ) _onFocus ( ) { if ( this . disabled ) { this . elementRef . nativeElement . blur ( ) ; } } _onPopoverWillHide ( ) { this . state = OpenState . closed ; this . elementRef . nativeElement . focus ( ) ; } @ HostListener ( 'keydown.enter' ) @ HostListener ( 'keydown.escape' ) @ HostListener ( 'blur' , [ '$event' ] ) _onBlur ( event ? ) { if ( this . disabled ) return ; if ( this . isOpen ) { if ( ! this . cardElement . nativeElement . contains ( event ?. relatedTarget as HTMLElement ) ) { this . close ( ) ; } } this . _onTouched ( ) ; } @ HostListener ( 'keydown.space' , [ '$event' ] ) _onSpace ( event ) { event . preventDefault ( ) ; event . stopPropagation ( ) ; if ( ! this . isOpen ) { this . open ( ) ; } } @ HostListener ( 'keydown.enter' , [ '$event' ] ) _onEnter ( event ) { event . preventDefault ( ) ; event . stopPropagation ( ) ; this . toggle ( ) ; } @ HostListener ( 'keydown.arrowup' , [ '$event' ] ) @ HostListener ( 'keydown.arrowdown' , [ '$event' ] ) @ HostListener ( 'keydown.arrowleft' , [ '$event' ] ) @ HostListener ( 'keydown.arrowright' , [ '$event' ] ) _onArrowKeys ( event ) { if ( this . disabled ) return ; if ( this . isOpen && ( event . key === 'ArrowLeft' || event . key === 'ArrowRight' ) ) { return ; } const newIndex = this . keyboardHandlerService . handle ( event , this . items , this . selectedIndex ) ; if ( newIndex > - 1 ) { this . selectItem ( newIndex ) ; } return false ; } @ HostListener ( 'keydown.home' , [ '$event' ] ) @ HostListener ( 'keydown.end' , [ '$event' ] ) _onHomeEndKeys ( event ) { if ( this . disabled ) return ; const newIndex = this . keyboardHandlerService . handle ( event , this . items , this . selectedIndex ) ; if ( newIndex > - 1 ) { this . selectItem ( newIndex ) ; } return false ; } ngOnDestroy ( ) { let unlisten ; while ( ( unlisten = this . itemClickUnlisten . pop ( ) ) !== undefined ) { unlisten ( ) ; } if ( this . intersectionObserverRef ) { this . intersectionObserverRef . disconnect ( ) ; } } }