import { fire, on, off } from '../Events/EventsManager'; import { extend } from '../Helpers/Extend'; import { wrap } from '../DOM/Wrap'; import { strToDOM } from '../DOM/StrToDOM'; import { index } from '../DOM/Index'; import { hClass, aClass } from '../DOM/Class'; import { rClass } from '../DOM/Class'; import { position } from '../DOM/Position'; import { height } from '../DOM/Size'; import { outerHeight } from '../DOM/OuterSize'; import quickTemplate from './QuickTemplate'; const defaultOptions: FLib.SkinSelect.Options = { "full": false, "extraClass": [], "className": "select-skin", "itemClassName": "select-itm", "selectWrapClassName": "select", "layerClassName": "select-layer", "listClassName": "select-list", "hoverItemClass": "hover", "openedListClass": "show", "activeOptionClass": "on", "disabledClass": "disabled", "invalidClass": "invalid", "loadingClass": "loading", "listTpl": { "wrapper": "
", "item": "
  • {{ text }}
  • " } }; /** * Skin an HTML select element. If options.full is set to true, also skin the options list. * You can access the skin API in the __skinAPI property of the $select HTMLElement or its wrapper. */ export default class SkinSelect implements FLib.SkinSelect.SkinSelect { #$select: FLib.SkinSelect.CustomSelect; #loading = false; #options: FLib.SkinSelect.Options; #extraClass: string; #$parent: FLib.SkinSelect.CustomSelectParent; #$title: HTMLElement; #isListOpened = false; #$options: NodeList | undefined; #$lastOption: HTMLElement | null = null; #focusedItemIndex = -1; #$layer: HTMLElement | undefined; constructor( $select: FLib.SkinSelect.CustomSelect, userOptions?: Partial ) { if ( $select.hasAttribute( 'multiple' ) ) { throw 'SkinSelect: This feature doesn\'t work on select with multiple selection'; } else if ( $select.__skinAPI ) { throw 'SkinSelect: Select already skinned'; } this.#$select = $select; this.#loading = false; this.#options = extend( defaultOptions, userOptions ); this.#extraClass = (this.#options.extraClass as string[]).join( ' ' ); if ( this.#$select.hasAttribute( 'data-class' ) ) { this.#extraClass = [ this.#extraClass, this.#$select.getAttribute( 'data-class' ) ].join( ' ' ); } this.#$parent = $select.parentNode as HTMLElement; // Create skin if ( !hClass( this.#$parent, this.#options.className ) ) { this.#$parent = wrap( this.#$select, `` ) as HTMLElement; } const $TITLE = this.#$parent.querySelector( `.${ this.#options.selectWrapClassName }`) as HTMLElement; if ( !$TITLE ) { this.#$title = document.createElement( 'SPAN' ); aClass( this.#$title, this.#options.selectWrapClassName ); this.#$parent.appendChild( this.#$title ); } else { this.#$title = $TITLE; } this.updateTitle(); // Also skin list if ( this.#options.full ) { this.updateOptions(); on( this.#$title, { "eventsName": "click", "callback": this.#openList } ); on( this.#$title, { "eventsName": "keydown", "callback": this.#onKeydown } ); on( this.#$title, { "eventsName": "keyup", "callback": this.#onKeyup } ); on( this.#$parent, { "eventsName": "click", "selector": `.${ this.#options.itemClassName }`, "callback": this.#fakeOptionsClickHandler } ); } this.#$select.__skinAPI = this.#$parent.__skinAPI = this; if ( this.#$select.hasAttribute('data-error') ) { this.setInvalid(); } on( this.#$select, { "eventsName": "change", "callback": this.#changeHandler } ); } #closeList = (): void => { this.#$parent.classList.remove( this.#options.openedListClass ); off( document.body, { "eventsName": "click", "callback": this.#closeList } ); this.#isListOpened = false; this.#removeItemFocus(); } #openList = (): void => { if ( this.#$select?.disabled || this.#loading ) { return; } if ( this.#isListOpened ) { this.#closeList(); return; } this.#$parent.classList.add( this.#options.openedListClass ); window.requestAnimationFrame( () => { on( document.body, { "eventsName": "click", "callback": this.#closeList } ); } ); if ( this.#$options ) { if ( this.#$lastOption ) { this.#focusedItemIndex = index( this.#$lastOption ); this.#focusedItemIndex = this.#focusedItemIndex > -1 ? this.#focusedItemIndex : 0; } this.#focusItem( this.#focusedItemIndex ); } this.#isListOpened = true; } #enableDisable = ( fnName: string, disabled: boolean ): void => { this.#$select.disabled = disabled; this.#$parent.classList[ fnName ]( this.#options.disabledClass ); } /** * Force the select to be enable */ enable(): this { this.#enableDisable( 'remove', false ); return this } /** * Force the select to be disable */ disable(): this { this.#enableDisable( 'add', true ); return this } #loadingStatus = ( fnName: string, isLoading: boolean ): void => { this.#loading = isLoading; this.#$parent.classList[ fnName ]( this.#options.loadingClass ); } /** * Add the loading css class to the main element */ setLoading(): this { this.#loadingStatus( 'add', true ); return this; } /** * Remove the loading css class to the main element */ unsetLoading(): this { this.#loadingStatus( 'remove', false ); return this; } #validInvalid = ( fnName: string ): void => { this.#$parent.classList[ fnName ]( this.#options.invalidClass ); } /** * Force the state of the select to invalid */ setInvalid(): this { this.#validInvalid( 'add' ); return this; } /** * Force the state of the select to valid */ setValid(): this { this.#validInvalid( 'remove' ); return this; } /** * Force the update of title with the currently selected element text */ updateTitle(): this { if ( this.#$select.selectedIndex < 0 ) { this.#$title.innerHTML = ''; this.#closeList(); return this; } const title = this.#$select.options[ this.#$select.selectedIndex ].text; this.#$title.innerHTML = title; this.#closeList(); return this; } selectByValue( value: string | number, dispatchEvent = true ): this { if ( this.#$select.disabled || this.#loading ) { return this; } const IDX = Array.from( this.#$select.options ).findIndex( option => option.value === String( value ) ); if ( IDX < 0 ) { return this; } return this.selectByIndex( IDX, dispatchEvent ); } selectByOption( optionOrItem: HTMLElement, dispatchEvent = true ): this { if ( this.#$select.disabled || this.#loading ) { return this; } const IDX = index( optionOrItem ) ; if ( IDX < 0 ) { return this; } return this.selectByIndex( IDX, dispatchEvent ); } /** * Select an option */ selectByIndex( index: number, dispatchEvent = true ): this { if ( this.#$select.disabled || this.#loading ) { return this; } if ( index < 0 || index > this.#$select.options.length ) { return this; } this.#$select.options[ index ].selected = true; if ( this.#options.full ) { ( this.#$parent.querySelectorAll( `.${ this.#options.itemClassName }` ) as NodeListOf) .forEach( ( $opt, optIdx ) => { $opt.selected = optIdx === index; $opt.classList.toggle( this.#options.activeOptionClass, $opt.selected ); if ( $opt.selected ) { this.#$lastOption = $opt; } } ); } if ( dispatchEvent ) { fire( this.#$select, { "eventsName": "change" } ); } else { this.updateTitle(); } return this; } /** * If arg is a number, it is considered as an option index, not a value. */ select( arg: number | string | HTMLElement, dispatchEvent = true ): this { if ( typeof arg === 'number' ) { return this.selectByIndex( arg, dispatchEvent ); } else if ( typeof arg === 'string' ) { return this.selectByValue( arg, dispatchEvent ); } else if ( typeof arg === 'object' ) { return this.selectByOption( arg, dispatchEvent ); } return this; } #setSelectOptions = ( data: FLib.SkinSelect.OptionArray[] ): void => { let hasSelectedOption; this.#$select.options.length = 0; const normalizedData = data.map( dt => { if ( dt.selected ) { hasSelectedOption = true; } return { "text": dt.text, "value": dt.value, "selected": dt.selected || false } } ); if ( !hasSelectedOption ) { normalizedData[ 0 ].selected = true; } normalizedData.forEach( ( optionData ) => { this.#$select.options.add( new Option( optionData.text, optionData.value, false, optionData.selected ) ); } ); } /** * Update the options list. If optionsArray is not set, only update the html of the skinned options list. * * @example * ```ts * selectAPI.updateOptions( [{"text": "Option 1", "value": "value 1", "selected": true}, ...] ) * ``` */ updateOptions( optionsArray?: FLib.SkinSelect.OptionArray[] ): this { this.#$lastOption = null; this.#focusedItemIndex = -1; if ( optionsArray ) { this.#setSelectOptions( optionsArray ); this.updateTitle(); } if ( !this.#options.full ) { return this; } this.#$select.style.display = 'none'; this.#$title.setAttribute( 'tabindex', '0' ); this.#closeList(); if ( this.#$layer && this.#$layer.parentNode ) { this.#$layer.parentNode.removeChild( this.#$layer ); } // let htmlList = template( this.#options.listTpl, { "list": this.#$select.options } ); const HTML_LIST: string[] = []; for ( const opt of Array.from( this.#$select.options ) ) { HTML_LIST.push( quickTemplate( this.#options.listTpl.item, { "onClass": opt.selected ? " on" : "", "text": opt.text, "value": opt.value, "itemClassName": this.#options.itemClassName } ) ); } this.#$parent.appendChild( strToDOM( quickTemplate( this.#options.listTpl.wrapper, { "items": HTML_LIST.join( '' ), "layerClassName": this.#options.layerClassName, "listClassName": this.#options.listClassName } ) ) ); this.#$layer = this.#$parent.querySelector( `.${ this.#options.layerClassName }` ) as HTMLElement; this.#$options = this.#$layer.querySelectorAll( 'li' ); this.#$lastOption = this.#$parent.querySelector( `li.${ this.#options.activeOptionClass }` ); return this; } #changeHandler = (): void => { this.updateTitle(); } // Handle click on the skinned ul>li list #fakeOptionsClickHandler = ( e: MouseEvent, $target: HTMLElement ): void => { if ( !$target.matches( '.' + this.#options.itemClassName ) ) { return; } this.selectByOption( $target ); } #focusItem = ( index: number ): void => { if ( !this.#$options ) { return; } if ( index < 0 ) { index = this.#$options.length - 1; } if ( index >= this.#$options.length ) { index = 0; } if ( !this.#$options[ index ] ) { return; } this.#removeItemFocus(); aClass( this.#$options[ index ], this.#options.hoverItemClass ); this.#updateListScroll( this.#$options[ index ] as HTMLElement ); this.#focusedItemIndex = index; } #removeItemFocus = (): void => { if ( this.#focusedItemIndex !== null && this.#$options && this.#$options[ this.#focusedItemIndex ] ) { rClass( this.#$options[ this.#focusedItemIndex ], this.#options.hoverItemClass ); this.#focusedItemIndex = -1; } } #updateListScroll = ( $item: HTMLElement ): void => { let itemPos, itemHeight, layerScrollTop, layerHeight; if ( this.#$layer ) { itemPos = position( $item ); itemHeight = outerHeight( $item ); layerHeight = height( this.#$layer ); layerScrollTop = this.#$layer.scrollTop; if ( itemPos.top + itemHeight > layerHeight + layerScrollTop) { this.#$layer.scrollTop = itemPos.top - layerHeight + itemHeight; } else if ( layerScrollTop > 0 && itemPos.top < layerScrollTop ) { this.#$layer.scrollTop = itemPos.top; } } } #onKeydown = ( e: KeyboardEvent ): void => { switch ( e.key ) { case "ArrowUp": case "ArrowDown": case "Enter": case "Escape": e.preventDefault(); break; } } #onKeyup = ( e: KeyboardEvent ): void => { switch ( e.key ) { case "ArrowUp": this.#focusItem( this.#focusedItemIndex - 1 ); break; case "ArrowDown": if ( !this.#isListOpened ) { this.#openList(); break; } this.#focusItem( this.#focusedItemIndex + 1 ); break; case "Enter": case " ": if ( !this.#isListOpened ) { this.#openList(); break; } this.select( this.#focusedItemIndex ); this.#closeList(); break; case "Escape": this.#closeList(); break; } } } /** * Skin a select DOM element * * @example * ```ts * // Call with default options: * skinSelect( $select, { * "full": false, * "extraClass": [], * "className": "select-skin", * "itemClassName": "select-itm", * "selectWrapClassName": "select", * "layerClassName": "select-layer", * "listClassName": "select-list", * "hoverItemClass": "hover", * "openedListClass": "show", * "activeOptionClass": "on", * "disabledClass": "disabled", * "invalidClass": "invalid", * "loadingClass": "loading", * "listTpl": { * "wrapper": "
      {{ items }}
    ", * "item": "
  • {{ text }}
  • " * } * ``` */ export function skinSelect( $select: HTMLSelectElement, options: Partial ): SkinSelect { return new SkinSelect( $select, options ); } /** * Skin all select DOM element in a wrapper * * @example * // See skinSelect for example of options * skinSelectAll( $wrapper, options ) */ export function skinSelectAll( $wrapper: HTMLElement, userOptions: Partial = {} ): SkinSelect[] { const skinList: SkinSelect[] = []; const defaultOptions: Partial = { "full": false, "extraClass": [] }; const options = extend( {}, defaultOptions, userOptions ); const $selects = $wrapper.querySelectorAll( options.selector || 'select' ); $selects.forEach( $select => { skinList.push( skinSelect( $select, options ) ); } ); return skinList; }