Source: ui/Dropdown.js

  1. import { UiElement } from "./UiElement.js";
  2. import "./Dropdown.css";
  3. export { Dropdown };
  4. /**
  5. * Dropdown menu
  6. *
  7. * @author rhess <robin.hess@awi.de>
  8. * @memberof vef.ui
  9. */
  10. class Dropdown extends UiElement {
  11. classes = 'vef-dropdown';
  12. html = `
  13. <div class='vef-dropdown-header' tabindex="0">
  14. <div class='vef-dropdown-header-inner'>
  15. <span class="preset-title"></span>
  16. <i class='fas fa-chevron-down'></i>
  17. </div>
  18. </div>
  19. <div class='vef-dropdown-menu'>
  20. <div class='vef-dropdown-search'></div>
  21. <div class='vef-dropdown-content'></div>
  22. </div>
  23. `;
  24. /**
  25. * options = {
  26. * items: object (object with entries for dropdown),
  27. * showDeselectItem: boolean
  28. * deselectLabel: string
  29. * }
  30. *
  31. * @param {HTMLElement | string} target
  32. * @param {object} options
  33. */
  34. constructor(target, options) {
  35. super(target, {
  36. "select": []
  37. });
  38. this.content_ = null;
  39. this.selected_ = null;
  40. this.searchbar_ = null;
  41. this.items_ = [];
  42. this.allItems_ = [];
  43. this.placeholder_ = (options && options.placeholder) ? options.placeholder : "select an item ...";
  44. this.deselectLabel_ = (options && (options.deselectLabel)) ? options.deselectLabel : "... deselect";
  45. this.showDeselectItem = (options && (typeof options.showDeselectItem == "boolean")) ? options.showDeselectItem : true;
  46. this.initElement_();
  47. if (options && options.items) this.setItems(options.items);
  48. if (options.search) this.initSearch_(options.searchPlaceholder);
  49. }
  50. /**
  51. * initialize the Ui element.
  52. * Called once in the constructor
  53. *
  54. * @private
  55. */
  56. initElement_() {
  57. this.setHtml(this.html);
  58. this.setClass(this.classes);
  59. this.query(".preset-title").innerText = this.placeholder_;
  60. let open = false;
  61. const event = () => {
  62. if (!open) {
  63. this.setClass("open");
  64. if (this.searchbar_) this.searchbar_.focus();
  65. document.body.addEventListener("click", event);
  66. document.body.addEventListener("keydown", (e) => {
  67. if (e.key === "Enter") event();
  68. });
  69. } else {
  70. this.removeClass("open");
  71. document.body.removeEventListener("click", event);
  72. document.body.addEventListener("keydown", (e) => {
  73. if (e.key === "Enter") event();
  74. });
  75. }
  76. open = !open;
  77. }
  78. this.getElement().addEventListener("keydown", (e) => {
  79. if (e.key === "Enter") {
  80. e.stopPropagation();
  81. if (e.target.nodeName == "INPUT") {
  82. return;
  83. } else {
  84. event();
  85. }
  86. }
  87. });
  88. this.getElement().addEventListener("click", (e) => {
  89. e.stopPropagation();
  90. if (e.target.nodeName == "INPUT") {
  91. return;
  92. } else {
  93. event();
  94. }
  95. });
  96. this.content_ = this.query(".vef-dropdown-content");
  97. }
  98. /**
  99. * helper method to add an element to deselect
  100. * the current value from the dropdown
  101. *
  102. * @private
  103. */
  104. addDeselectItem_() {
  105. if (this.showDeselectItem) {
  106. const deselectNodes = [...this.content_.childNodes].filter(el => el.textContent === this.deselectLabel_);
  107. if (deselectNodes.length === 0) {
  108. const element = this.createItemElement_(this.deselectLabel_, null, null);
  109. element.onclick = () => {
  110. this.deselect();
  111. this.fire("select", null);
  112. };
  113. this.content_.prepend(element);
  114. }
  115. }
  116. }
  117. /**
  118. * Removes the helper method set by addDeselectItem_()
  119. *
  120. * @private
  121. */
  122. removeDeselectItem_() {
  123. if (this.showDeselectItem) {
  124. const deselectNodes = [...this.content_.childNodes].filter(el => el.textContent === this.deselectLabel_);
  125. deselectNodes.forEach((node) => {
  126. this.content_.removeChild(node)
  127. })
  128. }
  129. }
  130. /**
  131. * Helper method for creating the
  132. * HTMLElement of an item
  133. *
  134. * @param {string} text
  135. * @param {string} title
  136. * @param {object} css
  137. * @returns {HTMLElement} element
  138. */
  139. createItemElement_(label, title, css) {
  140. const element = document.createElement("div");
  141. element.setAttribute("tabindex", "0")
  142. element.classList.add("vef-dropdown-item");
  143. if (css) {
  144. for (let i in css) {
  145. element.style[i] = css[i];
  146. }
  147. }
  148. const text = document.createElement("div");
  149. text.classList.add("vef-dropdown-item-text");
  150. text.title = title;
  151. text.innerText = label;
  152. element.appendChild(text);
  153. return element;
  154. }
  155. /**
  156. * called when an item gets selected.
  157. * Sets the name in the Ui
  158. *
  159. * @param {object} item
  160. * @private
  161. */
  162. select_(item) {
  163. if (!item && !this.selected_) return false;
  164. const title = this.query(".vef-dropdown-header .preset-title");
  165. if (item) {
  166. title.innerText = item.key;
  167. this.selected_ = item;
  168. this.addDeselectItem_();
  169. } else {
  170. title.innerText = this.placeholder_;
  171. this.selected_ = null;
  172. }
  173. return true;
  174. }
  175. /**
  176. * Set the available items in the dropdown
  177. *
  178. * @param {object} items
  179. */
  180. setItems(items) {
  181. this.allItems_ = items;
  182. this.setItems_(items);
  183. }
  184. /**
  185. * Internal method to set the visible items
  186. *
  187. * @param {object} items
  188. * @private
  189. */
  190. setItems_(items) {
  191. this.clear();
  192. if (this.selected_)
  193. this.addDeselectItem_();
  194. for (let i in items) {
  195. this.addItem(i, items[i]);
  196. }
  197. }
  198. /**
  199. * Add an item to the dropdown
  200. *
  201. * @param {string} key
  202. * @param {*} item
  203. * @param {object} css (optional) styling for dropdown item
  204. */
  205. addItem(key, item, css) {
  206. const element = this.createItemElement_(key, item.title, css)
  207. const itemWrapper = {
  208. key: key,
  209. item: item
  210. };
  211. element.onclick = () => {
  212. this.select_(itemWrapper);
  213. this.fire("select", itemWrapper);
  214. };
  215. element.addEventListener("keydown", (e) => {
  216. if (e.key === "Enter") {
  217. this.select_(itemWrapper);
  218. this.fire("select", itemWrapper);
  219. }
  220. })
  221. this.content_.appendChild(element);
  222. this.items_.push(itemWrapper);
  223. }
  224. /**
  225. * Remove all items
  226. */
  227. clear() {
  228. this.content_.innerHTML = "";
  229. this.items_ = [];
  230. }
  231. /**
  232. * Get the currently selected color item
  233. */
  234. getSelectedItem() {
  235. return this.selected_;
  236. }
  237. /**
  238. * Set the dropdown menu to deselected.
  239. */
  240. deselect() {
  241. this.removeDeselectItem_()
  242. this.select_(null);
  243. }
  244. /**
  245. * Selet an item by the given key
  246. *
  247. * @param {string} key
  248. */
  249. select(key) {
  250. for (let i = 0; i < this.items_.length; ++i) {
  251. const item = this.items_[i];
  252. if (item.key == key) {
  253. this.select_(item);
  254. return;
  255. }
  256. }
  257. }
  258. /**
  259. * Add search in titles if needed
  260. *
  261. * @private
  262. */
  263. initSearch_(placeholder) {
  264. let search = this.query(".vef-dropdown-search")
  265. search.innerHTML = `<input spellcheck="false" placeholder="${(placeholder) ? placeholder : 'search items ...'}" class="query" type="text"/>`;
  266. const input = search.querySelector(".query");
  267. input.addEventListener("focus", (e) => {
  268. e.stopPropagation();
  269. });
  270. // init search events
  271. let searchTimeout = null;
  272. input.addEventListener("input", () => {
  273. clearTimeout(searchTimeout);
  274. searchTimeout = setTimeout(() => {
  275. this.search(input.value.toLowerCase().trim());
  276. searchTimeout = null;
  277. }, 750);
  278. });
  279. input.addEventListener("keydown", (e) => {
  280. if (e.key == "Enter") {
  281. clearTimeout(searchTimeout);
  282. this.search(input.value.toLowerCase().trim());
  283. }
  284. });
  285. this.searchbar_ = input;
  286. }
  287. /**
  288. * Search in titles and add to dropdown
  289. * @param {string} query
  290. */
  291. search(query) {
  292. /**
  293. * Computes the size of a text based on the font.
  294. * Source: Domi (2014, January 9). Calculate text width with JavaScript. Stackoverflow. https://stackoverflow.com/a/21015393.
  295. * @param {String} text - Text.
  296. * @param {String} font - CSS font, i.e., 'bold 14px "PT Sans", sans-serif'.
  297. * @returns {Number} Text width.
  298. */
  299. function _getTextWidth(text, font) {
  300. // re-usage of canvas object for better performance
  301. const canvas = _getTextWidth.canvas || (_getTextWidth.canvas = document.createElement("canvas"));
  302. const context = canvas.getContext("2d");
  303. context.font = font;
  304. const metrics = context.measureText(text);
  305. return metrics.width;
  306. }
  307. const font = 'bold ' + getComputedStyle(this.getElement()).font;
  308. const availableTextSpace = this.getElement().querySelector("input").clientWidth;
  309. const matchedObjectEntries = Object.entries(this.allItems_).filter(
  310. ([key, val]) => key.toLowerCase().includes(query)
  311. )
  312. const matchedObjectEntriesShortened = matchedObjectEntries.map(([key, value]) => {
  313. const queryPosition = key.toLowerCase().indexOf(query);
  314. let keyShortened;
  315. let offset = 0;
  316. const requiredTextSpace = Math.ceil(_getTextWidth(key, font));
  317. while (Math.ceil(_getTextWidth(keyShortened, font)) < availableTextSpace) {
  318. if (queryPosition > offset && requiredTextSpace > availableTextSpace) {
  319. keyShortened = "..." + key.toLowerCase().substring(queryPosition - offset);
  320. offset += 1
  321. }
  322. else break
  323. }
  324. return [keyShortened || key, value]
  325. })
  326. const filtered = Object.fromEntries(matchedObjectEntriesShortened);
  327. this.setItems_(filtered);
  328. }
  329. }