@siberiaweb/components
Version:
691 lines (566 loc) • 20.9 kB
text/typescript
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();
}
}