@siberiaweb/components
Version:
529 lines (528 loc) • 19.5 kB
JavaScript
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;