UNPKG

@siberiaweb/components

Version:
529 lines (528 loc) 19.5 kB
import CloseEvent from "./CloseEvent"; import CSS from "./CSS"; 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 { /** * Конструктор. */ constructor() { super(); /** * Размер позиции по вертикали. */ this._itemHeight = DropdownList.DEFAULT_ITEM_HEIGHT; /** * Максимальная размер списка по вертикали. */ this._maxHeight = DropdownList.DEFAULT_MAX_HEIGHT; /** * Позиции. */ this.items = []; /** * Выведенные позиции: ( позиция, элемент ). */ this.renderedItems = new Map(); /** * Выбранная позиция. */ this.selectedItem = null; /** * Контейнер выведенного списка. */ this.renderedListContainer = document.createElement("div"); /** * Обработчик пользовательского вывода позиции. */ this.onItemCustomRender = null; /** * Обработчик проверки, что позицию можно выбрать. */ this.onItemCheckSelectable = null; /** * Индекс первой выведенной позиции. */ this.firstRenderedItemIndex = 0; /** * Индекс последней выведенной позиции. */ this.lastRenderedItemIndex = 0; /** * Флаг предотвращения вывода списка после изменения положения прокрутки. */ this.preventRenderListOnScroll = false; this.lightDOMFragment.appendChild(this.renderedListContainer); this.initDropdownListHost(); } /** * Инициализация хоста. */ initDropdownListHost() { this.addEventListener("scroll", () => { if (this.preventRenderListOnScroll) { this.preventRenderListOnScroll = false; } else { this.renderList(); } }); } /** * Создание элемента позиции. * * @param item Позиция. */ createItemElement(item) { let element = document.createElement("div", { is: ItemElement.COMPONENT_NAME }); 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", () => { if (this.isOpened()) { this.selectItem(item); } }); element.addEventListener("mousedown", (event) => { if (this.isOpened() && !(event.altKey || event.ctrlKey || event.shiftKey)) { this.dispatchEvent(new ItemClickEvent(item)); event.preventDefault(); } }); return element; } /** * Вывод позиции. * * @param item Позиция. * @param element Элемент. */ renderItem(item, element) { element.getContent().innerHTML = item.getText(); } /** * Вывод позиций. * * @param startIndex Индекс позиции, с которой необходимо начать вывод. * @param endIndex Индекс позиции, которой необходимо закончить вывод. */ renderItems(startIndex, endIndex) { this.renderedItems.clear(); let documentFragment = document.createDocumentFragment(); for (let i = startIndex; i <= endIndex; i++) { let item = this.items[i]; let element = 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 Индекс первой отображаемой позиции. Опционально. По умолчанию рассчитывается * автоматически. */ renderList(force = false, firstVisibleItemIndex) { if (firstVisibleItemIndex === undefined) { firstVisibleItemIndex = Math.floor(this.scrollTop / this._itemHeight); } let visibleItemCount = Math.ceil(this._maxHeight / this._itemHeight); let lastVisibleItemIndex = 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 = visibleItemCount * 3; let startRenderItemIndex = 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 = startRenderItemIndex * this._itemHeight; let extraBelowHeight = (this.items.length - 1 - endRenderItemIndex) * this._itemHeight; let extraItemAbove = document.createElement("div"); extraItemAbove.style.height = extraAboveHeight + "px"; let extraItemBelow = document.createElement("div"); extraItemBelow.style.height = extraBelowHeight + "px"; let 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 = this.selectedItem.item; let element = this.renderedItems.get(item); if (element !== undefined) { this.selectedItem = element; this.selectedItem.select(); } } } /** * @override */ 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 Позиция. */ isItemSelectable(item) { return !item.isGroup() && ((this.onItemCheckSelectable === null) || this.onItemCheckSelectable(item)); } /** * Проверка, что выпадающий список открыт. */ isOpened() { return this.hasAttribute(DropdownList.ATTR_OPENED); } /** * Открытие выпадающего списка. */ open() { if (this.isOpened()) { return; } if (this.dispatchEvent(new OpenEvent())) { this.toggleAttribute(DropdownList.ATTR_OPENED, true); } } /** * Закрытие выпадающего списка. */ close() { if (!this.isOpened()) { return; } if (this.dispatchEvent(new CloseEvent())) { this.toggleAttribute(DropdownList.ATTR_OPENED, false); } } /** * Отмена выбора позиции. */ unselectItem() { if (this.selectedItem === null) { return; } this.selectedItem.unselect(); this.selectedItem = null; } /** * Выбор позиции. * * @param item Позиция. * @param scrollTopPosition Позиция вертикального скроллинга, чтобы выбранная позиция стала видна. Опционально. * По умолчанию "nearest". */ selectItem(item, scrollTopPosition = "nearest") { 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 = 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)); } /** * Получение максимального количества позиций, которое можно отобразить в порте вывода. */ getMaxDisplayItemCountInViewport() { return Math.ceil(this.clientHeight / this._itemHeight); } /** * Получение ближайшей выбираемой позиции, относительно текущей выбранной. * * @param skipItemCount Количество пропускаемых позиций - положительное значение для поиска вниз и отрицательное для * поиска вверх. */ getNearestSelectableItem(skipItemCount) { if (this.selectedItem === null) { return null; } let selectableItem = null; let selectedItemIndex = this.items.indexOf(this.selectedItem.item); let targetItemIndex = 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 = targetItemIndex; i >= 0; i--) { let item = this.items[i]; if (this.isItemSelectable(item)) { selectableItem = item; break; } } if (selectableItem === null) { for (let i = targetItemIndex + 1; i < selectedItemIndex; i++) { let item = this.items[i]; if (this.isItemSelectable(item)) { selectableItem = item; break; } } } } else { for (let i = targetItemIndex; i < this.items.length; i++) { let item = this.items[i]; if (this.isItemSelectable(item)) { selectableItem = item; break; } } if (selectableItem === null) { for (let i = targetItemIndex - 1; i > selectedItemIndex; i--) { let item = this.items[i]; if (this.isItemSelectable(item)) { selectableItem = item; break; } } } } return selectableItem; } /** * Выбор первой позиции. */ selectFirstItem() { for (const item of this.items) { if (this.isItemSelectable(item)) { this.selectItem(item); break; } } } /** * Выбор последней позиции. */ selectLastItem() { for (let i = this.items.length - 1; i >= 0; i--) { let item = this.items[i]; if (this.isItemSelectable(item)) { this.selectItem(item); break; } } } /** * Выбор предыдущей позиции относительно текущей выбранной. */ selectPrevItem() { let selectableItem = this.getNearestSelectableItem(-1); if (selectableItem !== null) { this.selectItem(selectableItem); } else { this.scrollTop -= this._itemHeight; } } /** * Выбор следующей позиции относительно текущей выбранной. */ selectNextItem() { let selectableItem = this.getNearestSelectableItem(1); if (selectableItem !== null) { this.selectItem(selectableItem); } else { this.scrollTop += this._itemHeight; } } /** * Выбор позиции на предыдущей странице относительно текущей выбранной. */ selectPrevPageItem() { let skipItemCount = this.getMaxDisplayItemCountInViewport(); if (skipItemCount > 1) { skipItemCount--; } let selectableItem = this.getNearestSelectableItem(-skipItemCount); if (selectableItem !== null) { this.selectItem(selectableItem); } else { this.scrollTop -= this.clientHeight; } } /** * Выбор позиции на следующей странице. */ selectNextPageItem() { let skipItemCount = this.getMaxDisplayItemCountInViewport(); if (skipItemCount > 1) { skipItemCount--; } let selectableItem = this.getNearestSelectableItem(skipItemCount); if (selectableItem !== null) { this.selectItem(selectableItem); } else { this.scrollTop += this.clientHeight; } } /** * Установка позиций. * * @param items Позиции. */ setItems(items) { this.unselectItem(); this.items = Array.from(items); this.toggleAttribute(DropdownList.ATTR_EMPTY, this.items.length === 0); this.scrollTop = 0; this.renderList(true); } /** * Проверка, что выпадающий список содержит указанную позицию. * * @param item Позиция. */ hasItem(item) { return this.items.includes(item); } /** * Обновление вывода позиций. */ update() { this.renderList(true); } /** * Получение выбранной позиции. */ getSelectedItem() { return this.selectedItem === null ? null : this.selectedItem.item; } /** * Получение размера позиции по вертикали. */ get itemHeight() { return this._itemHeight; } /** * Установка размера позиции по вертикали. * * @param value Значение. */ set itemHeight(value) { if (value < 1) { value = DropdownList.DEFAULT_ITEM_HEIGHT; } this._itemHeight = value; this.renderList(true); } /** * Получение максимального размера списка по вертикали. */ get maxHeight() { return this._maxHeight; } /** * Установка максимального размера списка по вертикали. * * @param value Значение. */ set maxHeight(value) { if (value < 1) { value = DropdownList.DEFAULT_MAX_HEIGHT; } this._maxHeight = value; this.style.maxHeight = this._maxHeight + "px"; this.renderList(true); } } /** * Выпадающий список пуст. */ DropdownList.ATTR_EMPTY = "empty"; /** * Выпадающий список открыт. */ DropdownList.ATTR_OPENED = "opened"; /** * Размер позиции по вертикали по умолчанию. */ DropdownList.DEFAULT_ITEM_HEIGHT = 40; /** * Максимальный размер списка по вертикали по умолчанию. */ DropdownList.DEFAULT_MAX_HEIGHT = 280;