blob: d9b2e0f7d0025f4e36613b057b58c3859d2435a3 [file] [log] [blame]
Akron756f5992024-07-17 11:34:36 +02001/**
2 * Scrollable drop-down menus with view filter.
3 *
4 * @author Nils Diewald
5 */
6/*
7 * TODO: Show the slider briefly on move (whenever screen is called).
8 * TODO: Ignore alt+ and strg+ key strokes.
9 * TODO: Should scroll to a chosen value after prefixing, if the chosen value is live
10 * TODO: Add a "title" to a menu that is not scrollable.
11 * TODO: Make the menu responsive by showing less items on smaller screens
12 * or anytime items would be outside the screen.
13 * TODO: Add a .match() method to items for scrolling and probably for prefixing.
14 * TODO: Add static header (for title, sortation fields, but also for menu points like "fragments" and "history".
15 * TODO: Support space separated list of prefixes so "co no" will highlight "common noun"
16 */
17
18import defaultItemClass from './menu/item';
19import defaultPrefixClass from './menu/prefix';
20import defaultLengthFieldClass from './menu/lengthField';
21import sliderClass from './menu/slider';
22
23const menuLimit = 8;
24
25export default class KalamarMenu {
26
27 /**
28 * List of items for drop down menu (complete).
29 * Only a sublist of the menu is filtered (live).
30 * Only a sublist of the filtered menu is visible (shown).
31 */
32
33 /**
34 * Create new Menu based on the action prefix
35 * and a list of menu items.
36 *
37 *
38 * Accepts an associative array containg the elements
39 * itemClass, prefixClass, lengthFieldClass
40 *
41 * @this {Menu}
42 * @constructor
43 * @param {string} Context prefix
44 * @param {Array.<Array.<string>>} List of menu items
45 */
46 constructor (list, params) {
47 return this._init(list, params);
48 }
49
50 // Initialize list
51 _init (list, params) {
52 if (params === undefined)
53 params = {};
54
55 const t = this;
56 t._notItemElements=3;
57
58 t._itemClass = params["itemClass"] || defaultItemClass;
59
60 // Add prefix object
61 if (params["prefixClass"] !== undefined) {
62 t._prefix = new params["prefixClass"]();
63 }
64 else {
65 t._prefix = new defaultPrefixClass();
66 };
67 t._prefix._menu = t;
68
69 // Add lengthField object
70 if (params["lengthFieldClass"] !== undefined) {
71 t._lengthField = new params["lengthFieldClass"]();
72 }
73 else {
74 t._lengthField = new defaultLengthFieldClass();
75 };
76 t._lengthField._menu = t;
77
78 // Initialize slider
79 t._slider = new sliderClass(t);
80
81 // Create the element
82 var el = document.createElement("ul");
83 el.style.outline = 0;
84 el.setAttribute('tabindex', 0);
85 el.classList.add('menu', 'roll');
86 el.appendChild(t._prefix.element());
87 el.appendChild(t._lengthField.element());
88 el.appendChild(t._slider.element());
89
90 // This has to be cleaned up later on
91 el["menu"] = t;
92
93 // Arrow keys
94 el.addEventListener(
95 'keydown',
96 t._keydown.bind(t),
97 false
98 );
99
100 // Strings
101 el.addEventListener(
102 'keypress',
103 t._keypress.bind(t),
104 false
105 );
106
107 // Mousewheel
108 el.addEventListener(
109 'wheel',
110 t._mousewheel.bind(t),
111 false
112 );
113
114 // Touch events
115 ['touchstart', 'touchend', 'touchmove'].forEach(
116 e => el.addEventListener(e, t._touch.bind(t), false)
117 );
118
119
120 t._el = el;
121
122 t._limit = menuLimit;
123
124 t._items = new Array(); //all childNodes, i.e. ItemClass, prefixClass
125
126 t.readItems(list);
127
128 t.dontHide = false;
129
130 return t;
131 }
132
133 // Read items to add to list
134 readItems (list) {
135 const t = this;
136
137 t._list = undefined; //filtered List containing all itemClass items
138
139 // Remove circular reference to "this" in items
140 for (let i = 0; i < t._items.length; i++) {
141 delete t._items[i]["_menu"];
142 delete t._items[i];
143 };
144
145 t._items = new Array();
146 t.removeItems();
147
148
149 // Initialize items
150 t._lengthField.reset();
151
152 // Initialize item list based on parameters
153 list.forEach(function(i){
154 const obj = new this._itemClass(i);
155
156 // This may become circular
157 obj["_menu"] = this;
158 this._lengthField.add(i);
159 this._items.push(obj);
160 }, t);
161
162 t._slider.length(t.liveLength())
163 .limit(t._limit)
164 .reInit();
165
166 t._firstActive = false;
167 // Show the first item active always?
168 t.offset = 0;
169 t.position = 0;
170 }
171
172 // Append item to list
173 append (item) {
174 const t = this;
175 // This is cyclic!
176 item["_menu"] = t;
177 t._list = undefined;
178 t.removeItems();
179 t._items.push(item);
180 t._lengthField.add([item.content().data]);
181 t._slider.length(t.liveLength()).reInit();
182 t._firstActive = false;
183 t.offset = 0;
184 t.position = 0;
185 }
186
187 // Initialize the item list
188 // returns true if the length of the resulting list is at least 1
189 // and there was a prefix value. Returns true if there was no prefix value set.
190 _initList () {
191 // Upon change also update alwaysmenu.js please
192 const t = this;
193
194 // Create a new list
195 if (t._list === undefined) {
196 t._list = [];
197 }
198 else if (t._list.length !== 0) {
199 t._boundary(false);
200 t._list.length = 0;
201 };
202
203 // Offset is initially zero
204 t.offset = 0;
205
206 // There is no prefix set
207 if (t.prefix().length <= 0) {
208
209 // add all items to the list and lowlight
210 let i = 0;
211 for (; i < t._items.length; i++) {
212 t._list.push(i);
213 t._items[i].lowlight();
214 };
215
216 t._slider.length(i).reInit();
217
218 return true;
219 };
220
221 /*
222 * There is a prefix set, so filter the list!
223 */
224 let pos;
225 const prefixList = t.prefix().toLowerCase().split(" ");
226
227 const items = [];
228 let maxPoints = 1; // minimum 1
229
230 // Iterate over all items and choose preferred matching items
231 // i.e. the matching happens at the word start
232 t._items.forEach(function(it, pos){
233
234 let points = 0;
235
236 prefixList.forEach(function(p) {
237
238 // Check if it matches at the beginning
239 if ((it.lcField().includes(" " + p))) {
240 points += 5;
241 }
242
243 // Check if it matches anywhere
244 else if (it.lcField().includes(p)) {
245 points += 1;
246 };
247 });
248
249 if (points > maxPoints) {
250 this._list = [pos];
251 maxPoints = points;
252 }
253 else if (points == maxPoints) {
254 this._list.push(pos);
255 }
256 }, t);
257
258 t._slider.length(t._list.length).reInit();
259
260 // Filter was successful - yeah!
261 return t._list.length > 0 ? true : false;
262 }
263
264
265 /**
266 * Destroy this menu
267 * (in case you don't trust the
268 * mark and sweep GC)!
269 */
270 destroy () {
271 // Upon change also update alwaysmenu.js please
272 const t = this;
273
274 // Remove circular reference to "this" in menu
275 if (t._el != undefined)
276 delete t._el["menu"];
277
278 // Remove circular reference to "this" in items
279 t._items.forEach(function(i) {
280 delete i["_menu"];
281 });
282
283 // Remove circular reference to "this" in prefix
284 delete t._prefix['_menu'];
285 delete t._lengthField['_menu'];
286 delete t._slider['_menu'];
287 }
288
289
290 /**
291 * Focus on this menu.
292 */
293 focus () {
294 this._el.focus();
295 }
296
297
298 // mouse wheel treatment
299 _mousewheel (e) {
300 const delta = e.deltaY / 120;
301 if (delta > 0)
302 this.next();
303 else if (delta < 0)
304 this.prev();
305 e.halt();
306 }
307
308
309 // touchmove treatment
310 _touch (e) {
311 const s = this.slider();
312
313 if (e.type === "touchstart") {
314 this._lastTouch = e.touches[0].clientY;
315 }
316 else if (e.type === "touchend") {
317 this._lastTouch = undefined;
318 }
319 else if (e.type === "touchmove") {
320 const to = e.touches[0];
321
322 // TODO:
323 // Instead of using 26px, choose the item height
324 // or use the menu height // shownItems
325
326 // s.movetoRel(t.clientY - this._initTouch);
327 if ((this._lastTouch + 26) < to.clientY) {
328 this.viewDown();
329 this._lastTouch = to.clientY;
330 }
331 else if ((this._lastTouch - 26) > to.clientY) {
332 this.viewUp();
333 this._lastTouch = to.clientY;
334 }
335 e.halt();
336 };
337 }
338
339 // Arrow key and prefix treatment
340 _keydown (e) {
341 //Upon change also update alwaysmenu.js please
342 const t = this;
343
344 switch (_codeFromEvent(e)) {
345
346 case 27: // 'Esc'
347 e.halt();
348 t.hide();
349 break;
350
351 case 38: // 'Up'
352 e.halt();
353 t.prev();
354 break;
355
356 case 33: // 'Page up'
357 e.halt();
358 t.pageUp();
359 break;
360
361 case 40: // 'Down'
362 e.halt();
363 t.next();
364 break;
365
366 case 34: // 'Page down'
367 e.halt();
368 t.pageDown();
369 break;
370
371 case 39: // 'Right'
372 if (t._prefix.active())
373 break;
374
375 const item = t.liveItem(t.position);
376
377 if (item["further"] !== undefined) {
378 item["further"].bind(item).apply();
379 };
380
381 e.halt();
382 break;
383
384 case 13: // 'Enter'
385 // Click on prefix
386 if (t._prefix.active())
387 t._prefix.onclick(e);
388
389 // Click on item
390 else
391 t.liveItem(t.position).onclick(e);
392 e.halt();
393 break;
394
395 case 8: // 'Backspace'
396 t._prefix.chop();
397 t.show();
398 e.halt();
399 break;
400 };
401 }
402
403
404 // Add characters to prefix
405 _keypress (e) {
406 if (e.charCode !== 0) {
407 e.halt();
408
409 // Add prefix
410 this._prefix.add(
411 String.fromCharCode(_codeFromEvent(e))
412 );
413
414 this.show();
415 };
416 }
417
418
419 /**
420 * Show a screen with a given offset
421 * in the viewport.
422 */
423 screen (nr) {
424 const t = this;
425
426 // Normalize negative values
427 if (nr < 0) {
428 nr = 0
429 }
430
431 // The shown list already shows everything
432 else if (t.liveLength() < t.limit()) {
433 return false;
434 }
435
436 // Move relatively to the next screen
437 else if (nr > (t.liveLength() - t.limit())) {
438 nr = (t.liveLength() - t.limit());
439 };
440
441 // no change
442 if (t.offset === nr)
443 return false;
444
445 t._showItems(nr);
446
447 return true;
448 }
449
450
451 /**
452 * Get the associated dom element.
453 */
454 element () {
455 return this._el;
456 }
457
458
459 /**
460 * Get the creator class for items
461 */
462 itemClass () {
463 return this._itemClass;
464 }
465
466
467 /**
468 * Get and set the numerical value
469 * for the maximum number of items visible.
470 */
471 limit (limit) {
472 if (arguments.length === 1) {
473 if (this._limit !== limit) {
474 this._limit = limit;
475 this._slider.limit(limit).reInit();
476 };
477 return this;
478 };
479 return this._limit;
480 }
481
482
483 /**
484 * Filter the list and make it visible.
485 * This is always called once the prefix changes.
486 *
487 * @param {string} Prefix for filtering the list
488 */
489 show (active) {
490 //Upon change please also update alwaysmenu.js and containermenu.js (only two lines new there)
491 const t = this;
492
493 // show menu based on initial offset
494 t._unmark(); // Unmark everything that was marked before
495 t.removeItems();
496
497 // Initialize the list
498 if (!t._initList()) {
499
500 // The prefix is not active
501 t._prefix.active(true);
502
503 // finally show the element
504 t._el.classList.add('visible');
505
506 return true;
507 };
508
509 let offset = 0;
510
511 // Set a chosen value to active and move the viewport
512 if (arguments.length === 1) {
513
514 // Normalize active value
515 if (active < 0) {
516 active = 0;
517 }
518 else if (active >= t.liveLength()) {
519 active = t.liveLength() - 1;
520 };
521
522 // Item is outside the first viewport
523 if (active >= t._limit) {
524 offset = active;
525 const newOffset = t.liveLength() - t._limit;
526 if (offset > newOffset) {
527 offset = newOffset;
528 };
529 };
530
531 t.position = active;
532 }
533
534 // Choose the first item
535 else if (t._firstActive) {
536 t.position = 0;
537 }
538
539 // Choose no item
540 else {
541 t.position = -1;
542 };
543
544 t.offset = offset;
545 t._showItems(offset); // Show new item list
546
547 // Make chosen value active
548 if (t.position !== -1) {
549 t.liveItem(t.position).active(true);
550 };
551
552 // The prefix is not active
553 t._prefix.active(false);
554
555 // finally show the element
556 t._el.classList.add('visible');
557
558 // Add classes for rolling menus
559 t._boundary(true);
560
561 return true;
562 }
563
564
565 /**
566 * Hide the menu and call the onHide callback.
567 */
568 hide () {
569 if (!this.dontHide) {
570 this.removeItems();
571 this._prefix.clear();
572 this.onHide();
573 this._el.classList.remove('visible');
574 }
575 // this._el.blur();
576 }
577
578
579 /**
580 * Function released when the menu hides.
581 * This method is expected to be overridden.
582 */
583 onHide () {}
584
585
586 /**
587 * Get the prefix for filtering,
588 * e.g. &quot;ve&quot; for &quot;verb&quot;
589 */
590 prefix (pref) {
591 if (arguments.length === 1) {
592 this._prefix.value(pref);
593 return this;
594 };
595 return this._prefix.value();
596 }
597
598
599 /**
600 * Get the lengthField object.
601 */
602 lengthField () {
603 return this._lengthField;
604 }
605
606
607 /**
608 * Get the associated slider object.
609 */
610 slider () {
611 return this._slider;
612 }
613
614
615 /**
616 * Delete all visible items from the menu element
617 */
618
619 removeItems () {
620 const liElements=this._el.getElementsByTagName("LI");
621 var ignoredCount = 0; //counts how many LI tag elements are not actually direct children
622 while (liElements.length>ignoredCount){
623 if (liElements[ignoredCount].parentNode === this._el){
624 this._el.removeChild(liElements[ignoredCount]);
625 } else {
626 ignoredCount++;
627 }
628 };
629 }
630
631
632
633 /**
634 * Get a specific item from the complete list
635 *
636 * @param {number} index of the list item
637 */
638 item (index) {
639 return this._items[index]
640 }
641
642
643 /**
644 * Get a specific item from the filtered list
645 *
646 * @param {number} index of the list item
647 * in the filtered list
648 */
649 liveItem (index) {
650 if (this._list === undefined)
651 if (!this._initList())
652 return;
653
654 return this._items[this._list[index]];
655 }
656
657
658 /**
659 * Get a specific item from the viewport list
660 *
661 * @param {number} index of the list item
662 * in the visible list
663 */
664 shownItem (index) {
665 if (index >= this.limit())
666 return;
667
668 return this.liveItem(this.offset + index);
669 }
670
671
672 /**
673 * Get the length of the full item list
674 */
675 length () {
676 return this._items.length;
677 }
678
679
680 /**
681 * Length of the filtered item list.
682 */
683 liveLength () {
684 if (this._list === undefined)
685 this._initList();
686 return this._list.length;
687 }
688
689
690 /**
691 * Make the next item in the filtered menu active
692 */
693 next () {
694 //Upon change please update alwaysmenu.js next
695 const t = this;
696
697 // No list
698 if (t.liveLength() === 0)
699 return;
700
701 // Deactivate old item
702 if (t.position !== -1 && !t._prefix.active()) {
703 t.liveItem(t.position).active(false);
704 };
705
706 // Get new active item
707 t.position++;
708 let newItem = t.liveItem(t.position);
709
710 // The next element is undefined - roll to top or to prefix
711 if (newItem === undefined) {
712
713 // Activate prefix
714 const prefix = this._prefix;
715
716 // Prefix is set and not active - choose!
717 if (prefix.isSet() && !prefix.active()) {
718 t.position--;
719 prefix.active(true);
720 return;
721 }
722
723 // Choose first item
724 else {
725 newItem = t.liveItem(0);
726 // choose first item
727 t.position = 0;
728 t._showItems(0);
729 };
730 }
731
732 // The next element is after the viewport - roll down
733 else if (t.position >= (t.limit() + t.offset)) {
734 t.screen(t.position - t.limit() + 1);
735 }
736
737 // The next element is before the viewport - roll up
738 else if (t.position <= t.offset) {
739 t.screen(t.position);
740 };
741
742 t._prefix.active(false);
743 newItem.active(true);
744 }
745
746
747 /*
748 * Make the previous item in the menu active
749 */
750 prev () {
751 //Upon Change please update alwaysmenu.js prev
752 const t = this;
753
754 // No list
755 if (t.liveLength() === 0)
756 return;
757
758 // Deactivate old item
759 if (!t._prefix.active()) {
760
761 // No active element set
762 if (t.position === -1) {
763 t.position = t.liveLength();
764 }
765
766 // No active element set
767 else {
768 t.liveItem(t.position--).active(false);
769 };
770 };
771
772 // Get new active item
773 let newItem = t.liveItem(t.position);
774
775 // The previous element is undefined - roll to bottom
776 if (newItem === undefined) {
777
778 // Activate prefix
779 const prefix = t._prefix;
780 let offset = t.liveLength() - t.limit();
781
782 // Normalize offset
783 offset = offset < 0 ? 0 : offset;
784
785 // Choose the last item
786 t.position = t.liveLength() - 1;
787
788 // Prefix is set and not active - choose!
789 if (prefix.isSet() && !prefix.active()) {
790 t.position++;
791 prefix.active(true);
792 t.offset = offset;
793 return;
794 }
795
796 // Choose last item
797 else {
798 newItem = t.liveItem(t.position);
799 t._showItems(offset);
800 };
801 }
802
803 // The previous element is before the view - roll up
804 else if (t.position < t.offset) {
805 t.screen(t.position);
806 }
807
808 // The previous element is after the view - roll down
809 else if (t.position >= (t.limit() + t.offset)) {
810 t.screen(t.position - t.limit() + 2);
811 };
812
813 t._prefix.active(false);
814 newItem.active(true);
815 }
816
817
818 /**
819 * Move the page up by limit!
820 */
821 pageUp () {
822 this.screen(this.offset - this.limit());
823 }
824
825
826 /**
827 * Move the page down by limit!
828 */
829 pageDown () {
830 this.screen(this.offset + this.limit());
831 }
832
833
834 /**
835 * Move the view one item up
836 */
837 viewUp () {
838 this.screen(this.offset - 1);
839 }
840
841
842 /**
843 * Move the view one item down
844 */
845 viewDown () {
846 this.screen(this.offset + 1);
847 }
848
849 /**
850 * Reset the prefix. Currently not used in regular menu.
851 */
852 reset () {
853 this.prefix("");
854 }
855
856 // Unmark all items
857 _unmark () {
858 this._list.forEach(function(it){
859 const item = this._items[it];
860 item.lowlight();
861 item.active(false);
862 }, this);
863 }
864
865
866 // Set boundary for viewport
867 _boundary (bool) {
868 if (this._list.length === 0)
869 return;
870
871 this.item(this._list[0]).noMore(bool);
872 this.item(this._list[this._list.length - 1]).noMore(bool);
873 }
874
875
876 // Append Items that should be shown
877 _showItems (off) {
878 const t = this;
879
880 // optimization: scroll down one step
881 if (t.offset === (off - 1)) {
882 t.offset = off;
883
884 // Remove the HTML node from the first item
885 // leave lengthField/prefix/slider
886 //console.log("_showItems, at _notItemElements is: ",t._el.children[this._notItemElements]);
887 t._el.removeChild(t._el.children[this._notItemElements]);
888
889 t._append(
890 t._list[t.offset + t.limit() - 1]
891 );
892 }
893
894 // optimization: scroll up one step
895 else if (t.offset === (off + 1)) {
896 t.offset = off;
897
898 // Remove the HTML node from the last item
899 //console.log("_showItems, at lastChild is: ",t._el.lastChild);
900 t._el.removeChild(t._el.lastChild);
901
902 t._prepend(t._list[t.offset]);
903 }
904
905 else {
906 t.offset = off;
907
908 // Remove all items
909 t.removeItems();
910
911 // Use list
912 let shown = 0;
913
914 for (let i = 0; i < t._list.length; i++) {
915
916 // Don't show - it's before offset
917 shown++;
918 if (shown <= off)
919 continue;
920
921 t._append(t._list[i]);
922
923 if (shown >= (t.limit() + off))
924 break;
925 };
926 };
927
928 // set the slider to the new offset
929 t._slider.offset(t.offset);
930 }
931
932
933 // Append item to the shown list based on index
934 _append (i) {
935 const item = this.item(i);
936
937 // Highlight based on prefix
938 if (this.prefix().length > 0) {
939 item.highlight(this.prefix().toLowerCase());
940 };
941
942 // Append element
943 this.element().appendChild(item.element());
944 }
945
946
947 // Prepend item to the shown list based on index
948 _prepend (i) {
949 const item = this.item(i);
950
951 // Highlight based on prefix
952 if (this.prefix().length > 0) {
953 item.highlight(this.prefix().toLowerCase());
954 };
955
956 const e = this.element();
957
958 // Append element after lengthField/prefix/slider
959 e.insertBefore(
960 item.element(),
961 e.children[this._notItemElements]
962 );
963 }
964};