UNPKG

@siberiaweb/components

Version:
691 lines (566 loc) 20.9 kB
import CloseEvent from "./CloseEvent"; import CSS from "./CSS"; import HandlerTypes from "./HandlerTypes"; import Item from "./Item"; import ItemClickEvent from "./ItemClickEvent"; import ItemElement from "./ItemElement"; import OpenEvent from "./OpenEvent"; import SelectEvent from "./SelectEvent"; import WebComponent from "@siberiaweb/webcomponent/lib/WebComponent"; import "./DropdownList.css"; /** * Выпадающий список. */ export default class DropdownList extends WebComponent { /** * Выпадающий список пуст. */ public static readonly ATTR_EMPTY: string = "empty"; /** * Выпадающий список открыт. */ public static readonly ATTR_OPENED: string = "opened"; /** * Размер позиции по вертикали по умолчанию. */ public static readonly DEFAULT_ITEM_HEIGHT: number = 40; /** * Максимальный размер списка по вертикали по умолчанию. */ public static readonly DEFAULT_MAX_HEIGHT: number = 280; /** * Размер позиции по вертикали. */ private _itemHeight: number = DropdownList.DEFAULT_ITEM_HEIGHT; /** * Максимальная размер списка по вертикали. */ private _maxHeight: number = DropdownList.DEFAULT_MAX_HEIGHT; /** * Позиции. */ private items: Item[] = []; /** * Выведенные позиции: ( позиция, элемент ). */ private readonly renderedItems: Map< Item, ItemElement > = new Map(); /** * Выбранная позиция. */ private selectedItem: ItemElement | null = null; /** * Контейнер выведенного списка. */ private readonly renderedListContainer: HTMLDivElement = document.createElement( "div" ); /** * Обработчик пользовательского вывода позиции. */ public onItemCustomRender: HandlerTypes.ItemCustomRender | null = null; /** * Обработчик проверки, что позицию можно выбрать. */ public onItemCheckSelectable: HandlerTypes.ItemCheckSelectable | null = null; /** * Индекс первой выведенной позиции. */ private firstRenderedItemIndex: number = 0; /** * Индекс последней выведенной позиции. */ private lastRenderedItemIndex: number = 0; /** * Флаг предотвращения вывода списка после изменения положения прокрутки. */ private preventRenderListOnScroll = false; /** * Инициализация хоста. */ private initDropdownListHost(): void { this.addEventListener( "scroll", (): void => { if ( this.preventRenderListOnScroll ) { this.preventRenderListOnScroll = false; } else { this.renderList(); } } ); } /** * Создание элемента позиции. * * @param item Позиция. */ protected createItemElement( item: Item ): ItemElement { let element: ItemElement = document.createElement( "div", { is: ItemElement.COMPONENT_NAME } ) as ItemElement; element.item = item; if ( item.isGroup() ) { element.classList.add( CSS.GROUP_ROLE ); } if ( item.isGrouped() ) { element.classList.add( CSS.GROUPED_ITEM ); } element.style.height = this._itemHeight + "px"; element.addEventListener( "mousemove", (): void => { if ( this.isOpened() ) { this.selectItem( item ); } } ); element.addEventListener( "mousedown", ( event: MouseEvent ): void => { if ( this.isOpened() && !( event.altKey || event.ctrlKey || event.shiftKey ) ) { this.dispatchEvent( new ItemClickEvent( item ) ); event.preventDefault(); } } ); return element; } /** * Вывод позиции. * * @param item Позиция. * @param element Элемент. */ protected renderItem( item: Item, element: ItemElement ): void { element.getContent().innerHTML = item.getText(); } /** * Вывод позиций. * * @param startIndex Индекс позиции, с которой необходимо начать вывод. * @param endIndex Индекс позиции, которой необходимо закончить вывод. */ protected renderItems( startIndex: number, endIndex: number ): DocumentFragment { this.renderedItems.clear(); let documentFragment: DocumentFragment = document.createDocumentFragment(); for ( let i: number = startIndex; i <= endIndex; i++ ) { let item: Item = this.items[ i ]; let element: ItemElement = this.createItemElement( item ); if ( ( this.onItemCustomRender === null ) || !this.onItemCustomRender( item, element ) ) { this.renderItem( item, element ); } documentFragment.appendChild( element ); this.renderedItems.set( item, element ); } this.firstRenderedItemIndex = startIndex; this.lastRenderedItemIndex = endIndex; return documentFragment; } /** * Вывод списка. * * @param force Вывод списка, даже если требуемые для вывода позиции уже выведены. Опционально. По умолчанию false. * @param firstVisibleItemIndex Индекс первой отображаемой позиции. Опционально. По умолчанию рассчитывается * автоматически. */ protected renderList( force: boolean = false, firstVisibleItemIndex?: number ) { if ( firstVisibleItemIndex === undefined ) { firstVisibleItemIndex = Math.floor( this.scrollTop / this._itemHeight ); } let visibleItemCount: number = Math.ceil( this._maxHeight / this._itemHeight ); let lastVisibleItemIndex: number = firstVisibleItemIndex + visibleItemCount; if ( lastVisibleItemIndex > this.items.length - 1 ) { lastVisibleItemIndex = this.items.length - 1; } // Требуемые для вывода позиции уже выведены? if ( !force && ( firstVisibleItemIndex >= this.firstRenderedItemIndex ) && ( lastVisibleItemIndex <= this.lastRenderedItemIndex ) ) { return; } this.renderedListContainer.innerHTML = ""; if ( this.items.length === 0 ) { return; } let renderItemCount: number = visibleItemCount * 3; let startRenderItemIndex: number = firstVisibleItemIndex - Math.ceil( renderItemCount / 2 ); if ( startRenderItemIndex < 0 ) { startRenderItemIndex = 0; } let endRenderItemIndex = lastVisibleItemIndex + Math.ceil( renderItemCount / 2 ); if ( endRenderItemIndex > this.items.length - 1 ) { endRenderItemIndex = this.items.length - 1; } let extraAboveHeight: number = startRenderItemIndex * this._itemHeight; let extraBelowHeight: number = ( this.items.length - 1 - endRenderItemIndex ) * this._itemHeight; let extraItemAbove: HTMLDivElement = document.createElement( "div" ); extraItemAbove.style.height = extraAboveHeight + "px"; let extraItemBelow: HTMLDivElement = document.createElement( "div" ); extraItemBelow.style.height = extraBelowHeight + "px"; let documentFragment: DocumentFragment = document.createDocumentFragment(); documentFragment.appendChild( extraItemAbove ); documentFragment.appendChild( this.renderItems( startRenderItemIndex, endRenderItemIndex ) ); documentFragment.appendChild( extraItemBelow ); this.renderedListContainer.appendChild( documentFragment ); if ( this.selectedItem !== null ) { let item: Item = this.selectedItem.item; let element: ItemElement | undefined = this.renderedItems.get( item ); if ( element !== undefined ) { this.selectedItem = element; this.selectedItem.select(); } } } /** * @override */ protected firstConnectedCallback() { super.firstConnectedCallback(); this.classList.add( CSS.DROPDOWN_LIST ); this.toggleAttribute( DropdownList.ATTR_EMPTY, this.items.length === 0 ); this.style.maxHeight = this._maxHeight + "px"; } /** * Проверка, что позицию можно выбрать. * * @param item Позиция. */ public isItemSelectable( item: Item ): boolean { return !item.isGroup() && ( ( this.onItemCheckSelectable === null ) || this.onItemCheckSelectable( item ) ); } /** * Проверка, что выпадающий список открыт. */ public isOpened(): boolean { return this.hasAttribute( DropdownList.ATTR_OPENED ); } /** * Открытие выпадающего списка. */ public open(): void { if ( this.isOpened() ) { return; } if ( this.dispatchEvent( new OpenEvent() ) ) { this.toggleAttribute( DropdownList.ATTR_OPENED, true ); } } /** * Закрытие выпадающего списка. */ public close(): void { if ( !this.isOpened() ) { return; } if ( this.dispatchEvent( new CloseEvent() ) ) { this.toggleAttribute( DropdownList.ATTR_OPENED, false ); } } /** * Отмена выбора позиции. */ public unselectItem(): void { if ( this.selectedItem === null ) { return; } this.selectedItem.unselect(); this.selectedItem = null; } /** * Выбор позиции. * * @param item Позиция. * @param scrollTopPosition Позиция вертикального скроллинга, чтобы выбранная позиция стала видна. Опционально. * По умолчанию "nearest". */ public selectItem( item: Item, scrollTopPosition: ScrollLogicalPosition = "nearest" ): void { if ( !this.isItemSelectable( item ) || ( ( this.selectedItem !== null ) && ( this.selectedItem.item === item ) ) ) { return; } this.unselectItem(); if ( !this.renderedItems.has( item ) ) { this.renderList( true, this.items.indexOf( item ) ); } let itemElement: ItemElement | undefined = this.renderedItems.get( item ); if ( itemElement === undefined ) { throw new Error( "Ошибка при выводе позиций." ); } this.selectedItem = itemElement; this.selectedItem.select(); switch ( scrollTopPosition ) { case "center": this.preventRenderListOnScroll = true; this.scrollTop = itemElement.offsetTop - Math.floor( this.clientHeight / 2 ) + Math.floor( itemElement.offsetHeight / 2 ); break; case "end": this.preventRenderListOnScroll = true; this.scrollTop = itemElement.offsetTop - this.clientHeight + itemElement.offsetHeight; break; case "start": this.preventRenderListOnScroll = true; this.scrollTop = itemElement.offsetTop; break; case "nearest": if ( itemElement.offsetTop < this.scrollTop ) { this.preventRenderListOnScroll = true; this.scrollTop = itemElement.offsetTop; } else { if ( itemElement.offsetTop + itemElement.offsetHeight > this.scrollTop + this.clientHeight ) { this.preventRenderListOnScroll = true; this.scrollTop = itemElement.offsetTop - this.clientHeight + itemElement.offsetHeight; } } break; } this.dispatchEvent( new SelectEvent( item ) ); } /** * Получение максимального количества позиций, которое можно отобразить в порте вывода. */ protected getMaxDisplayItemCountInViewport(): number { return Math.ceil( this.clientHeight / this._itemHeight ); } /** * Получение ближайшей выбираемой позиции, относительно текущей выбранной. * * @param skipItemCount Количество пропускаемых позиций - положительное значение для поиска вниз и отрицательное для * поиска вверх. */ protected getNearestSelectableItem( skipItemCount: number ): Item | null { if ( this.selectedItem === null ) { return null; } let selectableItem: Item | null = null; let selectedItemIndex: number = this.items.indexOf( this.selectedItem.item ); let targetItemIndex: number = selectedItemIndex + skipItemCount; if ( targetItemIndex < 0 ) { targetItemIndex = 0; } if ( targetItemIndex > this.items.length - 1 ) { targetItemIndex = this.items.length - 1; } if ( targetItemIndex === selectedItemIndex ) { return null; } if ( skipItemCount < 0 ) { for ( let i: number = targetItemIndex; i >= 0; i-- ) { let item = this.items[ i ]; if ( this.isItemSelectable( item ) ) { selectableItem = item; break; } } if ( selectableItem === null ) { for ( let i: number = targetItemIndex + 1; i < selectedItemIndex; i++ ) { let item = this.items[ i ]; if ( this.isItemSelectable( item ) ) { selectableItem = item; break; } } } } else { for ( let i: number = targetItemIndex; i < this.items.length; i++ ) { let item = this.items[ i ]; if ( this.isItemSelectable( item ) ) { selectableItem = item; break; } } if ( selectableItem === null ) { for ( let i: number = targetItemIndex - 1; i > selectedItemIndex; i-- ) { let item = this.items[ i ]; if ( this.isItemSelectable( item ) ) { selectableItem = item; break; } } } } return selectableItem; } /** * Выбор первой позиции. */ public selectFirstItem(): void { for ( const item of this.items ) { if ( this.isItemSelectable( item ) ) { this.selectItem( item ); break; } } } /** * Выбор последней позиции. */ public selectLastItem(): void { for ( let i: number = this.items.length - 1; i >= 0; i-- ) { let item: Item = this.items[ i ]; if ( this.isItemSelectable( item ) ) { this.selectItem( item ); break; } } } /** * Выбор предыдущей позиции относительно текущей выбранной. */ public selectPrevItem(): void { let selectableItem: Item | null = this.getNearestSelectableItem( -1 ); if ( selectableItem !== null ) { this.selectItem( selectableItem ); } else { this.scrollTop -= this._itemHeight; } } /** * Выбор следующей позиции относительно текущей выбранной. */ public selectNextItem(): void { let selectableItem: Item | null = this.getNearestSelectableItem( 1 ); if ( selectableItem !== null ) { this.selectItem( selectableItem ); } else { this.scrollTop += this._itemHeight; } } /** * Выбор позиции на предыдущей странице относительно текущей выбранной. */ public selectPrevPageItem(): void { let skipItemCount: number =this.getMaxDisplayItemCountInViewport(); if ( skipItemCount > 1 ) { skipItemCount--; } let selectableItem: Item | null = this.getNearestSelectableItem( -skipItemCount ); if ( selectableItem !== null ) { this.selectItem( selectableItem ); } else { this.scrollTop -= this.clientHeight; } } /** * Выбор позиции на следующей странице. */ public selectNextPageItem(): void { let skipItemCount: number =this.getMaxDisplayItemCountInViewport(); if ( skipItemCount > 1 ) { skipItemCount--; } let selectableItem: Item | null = this.getNearestSelectableItem( skipItemCount ); if ( selectableItem !== null ) { this.selectItem( selectableItem ); } else { this.scrollTop += this.clientHeight; } } /** * Установка позиций. * * @param items Позиции. */ public setItems( items: Item[] ): void { this.unselectItem(); this.items = Array.from( items ); this.toggleAttribute( DropdownList.ATTR_EMPTY, this.items.length === 0 ); this.scrollTop = 0; this.renderList( true ); } /** * Проверка, что выпадающий список содержит указанную позицию. * * @param item Позиция. */ public hasItem( item: Item ): boolean { return this.items.includes( item ); } /** * Обновление вывода позиций. */ public update(): void { this.renderList( true ); } /** * Получение выбранной позиции. */ public getSelectedItem(): Item | null { return this.selectedItem === null ? null : this.selectedItem.item; } /** * Получение размера позиции по вертикали. */ public get itemHeight(): number { return this._itemHeight; } /** * Установка размера позиции по вертикали. * * @param value Значение. */ public set itemHeight( value: number ) { if ( value < 1 ) { value = DropdownList.DEFAULT_ITEM_HEIGHT; } this._itemHeight = value; this.renderList( true ); } /** * Получение максимального размера списка по вертикали. */ public get maxHeight(): number { return this._maxHeight; } /** * Установка максимального размера списка по вертикали. * * @param value Значение. */ public set maxHeight( value: number ) { if ( value < 1 ) { value = DropdownList.DEFAULT_MAX_HEIGHT; } this._maxHeight = value; this.style.maxHeight = this._maxHeight + "px"; this.renderList( true ); } /** * Конструктор. */ constructor() { super(); this.lightDOMFragment.appendChild( this.renderedListContainer ); this.initDropdownListHost(); } }