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": "",
* "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;
}