import { Component } from "./component.js";
import { Label } from "./label.js";
import { Style } from "./style.js";
/**
* Provides a dropdown list of items when clicked. One of those items can then be selected and be shown in the main component.
* <div><img src="https://www.minicomps.org/images/dropdown.png"/></div>
* @example
* const panel = new Panel(document.body, 20, 20, 200, 200);
* const items = ["Item 1", "Item 2", "Item 3"];
* new Dropdown(panel, 20, 20, items, 0, event => console.log(event.target.text));
* @extends Component
*/
export class Dropdown extends Component {
/**
* Constructor
* @param {HTMLElement} parent - The element to add this dropdown to.
* @param {number} x - The x position of the dropdown. Default 0.
* @param {number} y - The y position of the dropdown. Default 0.
* @param {array} items - An array of strings to populate the dropdown list with. Default empty array.
* @param {number} index - The initial selected index of the dropdown. default -1.
* @param {function} defaultHandler - A function that will handle the "change" event.
*/
constructor(parent, x, y, items, index, defaultHandler) {
super(parent, x, y);
this._items = items || [];
this._open = false;
this._itemElements = [];
this._text = "";
this._createChildren();
this._createStyle();
this._createListeners();
this.setSize(Dropdown.width, Dropdown.height);
this._createItems();
this.setIndex(index);
this.setDropdownPosition(Dropdown.dropdownPosition);
this.addEventListener("change", defaultHandler);
this._addToParent();
}
//////////////////////////////////
// Core
//////////////////////////////////
_createChildren() {
this._setWrapperClass("MinimalDropdown");
this._wrapper.tabIndex = 0;
this._textLabel = new Label(this._wrapper, 3, 3);
this._textLabel.autosize = false;
this._button = this._createDiv(this._wrapper, "MinimalDropdownButton");
this._button.textContent = "+";
this._dropdown = this._createDiv(this._wrapper, "MinimalDropdownPanel");
}
_createItems() {
for (let i = 0; i < this._items.length; i++) {
this._createItem(i);
}
}
_createItem(index) {
const item = this._createDiv(this._dropdown, "MinimalDropdownItem");
item.setAttribute("data-index", index);
item.addEventListener("click", this._onItemClick);
item.tabIndex = 0;
const label = new Label(item, 3, 0, this._items[index]);
label.y = (this._height - label.height) / 2;
label.autosize = false;
const itemObj = {item, label};
this._updateItem(itemObj, index);
this._itemElements.push(itemObj);
return item;
}
_createStyle() {
const style = document.createElement("style");
style.textContent = Style.dropdown;
this.shadowRoot.append(style);
}
_createListeners() {
this._toggle = this._toggle.bind(this);
this._onItemClick = this._onItemClick.bind(this);
this._onKeyPress = this._onKeyPress.bind(this);
this._wrapper.addEventListener("click", () => {
this._toggle();
});
for (let i = 0; i < this._itemElements.length; i++) {
this._itemElements[i].addEventListener("click", this._onItemClick);
}
this.addEventListener("keydown", this._onKeyPress);
this.addEventListener("blur", () => this.close());
}
//////////////////////////////////
// Handlers
//////////////////////////////////
_toggle() {
this._open = !this._open;
if (this._open) {
this._initialZ = this.style.zIndex;
this.style.zIndex = Style.popupZIndex;
this._dropdown.style.display = "block";
} else {
this.style.zIndex = this._initialZ;
this._dropdown.style.display = "none";
}
}
_onItemClick(event) {
event.stopPropagation();
this.setIndex(event.currentTarget.getAttribute("data-index"));
this._toggle();
this.dispatchEvent(new CustomEvent("change", {
detail: {
text: this._text,
index: this._index,
},
}));
this._wrapper.focus();
}
_onKeyPress(event) {
if (event.keyCode === 13 && this._enabled) {
// enter
this.shadowRoot.activeElement.click();
} else if (event.keyCode === 27) {
// escape
this.close();
} else if (event.keyCode === 40) {
// down
event.preventDefault();
if (this.shadowRoot.activeElement === this._wrapper ||
this.shadowRoot.activeElement === this._dropdown.lastChild) {
this._dropdown.firstChild.focus();
} else {
this.shadowRoot.activeElement.nextSibling.focus();
}
} else if (event.keyCode === 38) {
// up
event.preventDefault();
if (this.shadowRoot.activeElement === this._wrapper ||
this.shadowRoot.activeElement === this._dropdown.firstChild) {
this._dropdown.lastChild.focus();
} else {
this.shadowRoot.activeElement.previousSibling.focus();
}
}
}
//////////////////////////////////
// Private
//////////////////////////////////
_updateButton() {
this._button.style.left = this._width - this._height - 1 + "px";
this._button.style.width = this._height + "px";
this._button.style.height = this._height + "px";
this._button.style.lineHeight = this._height - 1 + "px";
}
_updateItem(itemObj, i) {
const { item, label } = itemObj;
const h = this._height - 1;
item.style.top = i * h + "px";
item.style.width = this._width + "px";
item.style.height = this._height + "px";
label.y = (this._height - label.height) / 2;
label.width = this._width - 8;
}
_updateDropdownPosition() {
if (this._dropdownPosition === "bottom") {
this._dropdown.style.top = this._height - 2 + "px";
} else if (this._dropdownPosition === "top") {
this._dropdown.style.top = -(this._height - 1) * this._items.length + "px";
}
this._dropdown.style.left = "-1px";
this._dropdown.style.width = this._width + "px";
this._dropdown.style.height = (this._height - 1) * this._items.length + "px";
}
//////////////////////////////////
// Public
//////////////////////////////////
/**
* Adds a handler function for the "change" event on this dropdown.
* @param {function} handler - A function that will handle the "change" event.
* @returns This instance, suitable for chaining.
*/
addHandler(handler) {
this.addEventListener("change", handler);
return this;
}
/**
* Automatically changes the value of a property on a target object with the main value of this component changes.
* @param {object} target - The target object to change.
* @param {string} prop - The string name of a property on the target object.
* @return This instance, suitable for chaining.
*/
bind(target, prop) {
this.addEventListener("change", event => {
target[prop] = event.detail;
});
return this;
}
/**
* Programatically closes the dropdown if it is open.
* @returns This instance, suitable for chaining.
*/
close() {
this._open = true;
this._toggle();
return this;
}
/**
* @returns the position of the dropdown.
*/
getDropDownPosition() {
return this._dropdownPosition;
}
/**
* @returns the currently selected index.
*/
getIndex() {
return this._index;
}
/**
* @returns the text of the currently selected item in the dropdown (read only).
*/
getText() {
return this._text;
}
/**
* Programatically opens the dropdown if it is closed.
* @returns This instance, suitable for chaining.
*/
open() {
this._open = false;
this._toggle();
return this;
}
/**
* Gets and sets the position of the dropdown list.
* @param {string} position - The position where the list will open. Valid values are "bottom" (default) and "top".
* @returns This instance, suitable for chaining.
*/
setDropdownPosition(position) {
this._dropdownPosition = position;
this._updateDropdownPosition();
return this;
}
setEnabled(enabled) {
if (this._enabled === enabled) {
return this;
}
super.setEnabled(enabled);
if (this._enabled) {
this._wrapper.addEventListener("click", this._toggle);
this._wrapper.setAttribute("class", "MinimalDropdown");
this._button.setAttribute("class", "MinimalDropdownButton");
this.tabIndex = 0;
} else {
this._wrapper.removeEventListener("click", this._toggle);
this._wrapper.setAttribute("class", "MinimalDropdownDisabled");
this._button.setAttribute("class", "MinimalDropdownButtonDisabled");
this.tabIndex = -1;
this.close();
}
return this;
}
setHeight(height) {
super.setHeight(height);
this._textLabel.y = (this._height - this._textLabel.height) / 2;
this._textLabel.width = this._width - this._height;
this._updateButton();
this._itemElements.forEach((item, i) => this._updateItem(item, i));
this._updateDropdownPosition();
return this;
}
/**
* Sets the selected index of this _dropdown.
* @param {number} index - The index to set.
* @returns This instance, suitable for chaining.
*/
setIndex(index) {
if (index < 0 || index >= this._items.length || index === null || index === undefined) {
this._index = -1;
this._text = "";
this._textLabel.text = "Choose...";
} else {
this._index = index;
this._text = this._items[this._index];
this._textLabel.text = this._text;
}
return this;
}
setWidth(width) {
super.setWidth(width);
this._textLabel.width = this._width - this._height;
this._dropdown.style.width = this._width + "px";
this._updateButton();
this._itemElements.forEach(item => {
this._updateItem(item);
});
return this;
}
//////////////////////////////////
// Getters/Setters
// alphabetical. getter first.
//////////////////////////////////
/**
* Gets and sets the position of the dropdown list. Valid values are "bottom" (default) and "top".
*/
get dropdownPosition() {
return this.getDropDownPosition();
}
set dropdownPosition(pos) {
this.setDropdownPosition(pos);
}
/**
* Reading this property tells you the index of the currently selected item. Setting it caused the new index to be selected and the dropdown to display that item.
*/
get index() {
return this.getIndex();
}
set index(index) {
this.setIndex(index);
}
/**
* Get the text of the currently selected item in the dropdown (read only).
*/
get text() {
return this.getText();
}
}
//////////////////////////////////
// DEFAULTS
//////////////////////////////////
/**
* Default width of all Dropdowns.
*/
Dropdown.width = 100;
/**
* Default height of all Dropdowns.
*/
Dropdown.height = 20;
/**
* Default dropdownPosition of all Dropdowns.
*/
Dropdown.dropdownPosition = "bottom";
customElements.define("minimal-dropdown", Dropdown);