summaryrefslogtreecommitdiffstats
path: root/web_src/js/webcomponents/overflow-menu.js
blob: a69ce1681ceb0cb74b675c68439c529cb37daf2f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
import {throttle} from 'throttle-debounce';
import {createTippy} from '../modules/tippy.js';
import {isDocumentFragmentOrElementNode} from '../utils/dom.js';
import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg';

window.customElements.define('overflow-menu', class extends HTMLElement {
  updateItems = throttle(100, () => {
    if (!this.tippyContent) {
      const div = document.createElement('div');
      div.classList.add('tippy-target');
      div.tabIndex = '-1'; // for initial focus, programmatic focus only
      div.addEventListener('keydown', (e) => {
        if (e.key === 'Tab') {
          const items = this.tippyContent.querySelectorAll('[role="menuitem"]');
          if (e.shiftKey) {
            if (document.activeElement === items[0]) {
              e.preventDefault();
              items[items.length - 1].focus();
            }
          } else {
            if (document.activeElement === items[items.length - 1]) {
              e.preventDefault();
              items[0].focus();
            }
          }
        } else if (e.key === 'Escape') {
          e.preventDefault();
          e.stopPropagation();
          this.button._tippy.hide();
          this.button.focus();
        } else if (e.key === ' ' || e.code === 'Enter') {
          if (document.activeElement?.matches('[role="menuitem"]')) {
            e.preventDefault();
            e.stopPropagation();
            document.activeElement.click();
          }
        } else if (e.key === 'ArrowDown') {
          if (document.activeElement?.matches('.tippy-target')) {
            e.preventDefault();
            e.stopPropagation();
            document.activeElement.querySelector('[role="menuitem"]:first-of-type').focus();
          } else if (document.activeElement?.matches('[role="menuitem"]')) {
            e.preventDefault();
            e.stopPropagation();
            document.activeElement.nextElementSibling?.focus();
          }
        } else if (e.key === 'ArrowUp') {
          if (document.activeElement?.matches('.tippy-target')) {
            e.preventDefault();
            e.stopPropagation();
            document.activeElement.querySelector('[role="menuitem"]:last-of-type').focus();
          } else if (document.activeElement?.matches('[role="menuitem"]')) {
            e.preventDefault();
            e.stopPropagation();
            document.activeElement.previousElementSibling?.focus();
          }
        }
      });
      this.append(div);
      this.tippyContent = div;
    }

    // move items in tippy back into the menu items for subsequent measurement
    for (const item of this.tippyItems || []) {
      this.menuItemsEl.append(item);
    }

    // measure which items are partially outside the element and move them into the button menu
    this.tippyItems = [];
    const menuRight = this.offsetLeft + this.offsetWidth;
    const menuItems = this.menuItemsEl.querySelectorAll('.item');
    const settingItem = this.menuItemsEl.querySelector('#settings-btn');
    for (const item of menuItems) {
      const itemRight = item.offsetLeft + item.offsetWidth;
      // Width of the settings button plus a small value to get the next item to the left if there is directly one
      // If no setting button is in the menu the default threshold is 38 - roughly the width of .overflow-menu-button
      const overflowBtnThreshold = 38;
      const threshold = settingItem?.offsetWidth ?? overflowBtnThreshold;
      // If we have a settings item on the right-hand side, we must also check if the first,
      // possibly overflowing item would still fit on the left-hand side of the overflow menu
      // If not, it must be added to the array (twice). The duplicate is removed with the shift.
      if (settingItem && !this.tippyItems?.length && item !== settingItem && menuRight - itemRight < overflowBtnThreshold) {
        this.tippyItems.push(settingItem);
      }
      if (menuRight - itemRight < threshold) {
        this.tippyItems.push(item);
      }
    }

    // Special handling for settings button on right. Only done if a setting item is present
    if (settingItem) {
      // If less than 2 items overflow, remove all items (only settings "overflowed" - because it's on the right side)
      if (this.tippyItems?.length < 2) {
        this.tippyItems = [];
      } else {
        // Remove the first item of the list, because we have always one item more in the array due to the big threshold above
        this.tippyItems.shift();
      }
    }

    // if there are no overflown items, remove any previously created button
    if (!this.tippyItems?.length) {
      const btn = this.querySelector('.overflow-menu-button');
      btn?._tippy?.destroy();
      btn?.remove();
      return;
    }

    // remove aria role from items that moved from tippy to menu
    for (const item of menuItems) {
      if (!this.tippyItems.includes(item)) {
        item.removeAttribute('role');
      }
    }

    // move all items that overflow into tippy
    for (const item of this.tippyItems) {
      item.setAttribute('role', 'menuitem');
      this.tippyContent.append(item);
    }

    // update existing tippy
    if (this.button?._tippy) {
      this.button._tippy.setContent(this.tippyContent);
      return;
    }

    // create button initially
    const btn = document.createElement('button');
    btn.classList.add('overflow-menu-button', 'btn', 'tw-px-2', 'hover:tw-text-text-dark');
    btn.setAttribute('aria-label', window.config.i18n.more_items);
    btn.innerHTML = octiconKebabHorizontal;
    this.append(btn);
    this.button = btn;

    createTippy(btn, {
      trigger: 'click',
      hideOnClick: true,
      interactive: true,
      placement: 'bottom-end',
      role: 'menu',
      content: this.tippyContent,
      onShow: () => { // FIXME: onShown doesn't work (never be called)
        setTimeout(() => {
          this.tippyContent.focus();
        }, 0);
      },
    });
  });

  init() {
    // for horizontal menus where fomantic boldens active items, prevent this bold text from
    // enlarging the menu's active item replacing the text node with a div that renders a
    // invisible pseudo-element that enlarges the box.
    if (this.matches('.ui.secondary.pointing.menu, .ui.tabular.menu')) {
      for (const item of this.querySelectorAll('.item')) {
        for (const child of item.childNodes) {
          if (child.nodeType === Node.TEXT_NODE) {
            const text = child.textContent.trim(); // whitespace is insignificant inside flexbox
            if (!text) continue;
            const span = document.createElement('span');
            span.classList.add('resize-for-semibold');
            span.setAttribute('data-text', text);
            span.textContent = text;
            child.replaceWith(span);
          }
        }
      }
    }

    // ResizeObserver triggers on initial render, so we don't manually call `updateItems` here which
    // also avoids a full-page FOUC in Firefox that happens when `updateItems` is called too soon.
    this.resizeObserver = new ResizeObserver((entries) => {
      for (const entry of entries) {
        const newWidth = entry.contentBoxSize[0].inlineSize;
        if (newWidth !== this.lastWidth) {
          requestAnimationFrame(() => {
            this.updateItems();
          });
          this.lastWidth = newWidth;
        }
      }
    });
    this.resizeObserver.observe(this);
  }

  connectedCallback() {
    this.setAttribute('role', 'navigation');

    // check whether the mandatory `.overflow-menu-items` element is present initially which happens
    // with Vue which renders differently than browsers. If it's not there, like in the case of browser
    // template rendering, wait for its addition.
    // The eslint rule is not sophisticated enough or aware of this problem, see
    // https://github.com/43081j/eslint-plugin-wc/pull/130
    const menuItemsEl = this.querySelector('.overflow-menu-items'); // eslint-disable-line wc/no-child-traversal-in-connectedcallback
    if (menuItemsEl) {
      this.menuItemsEl = menuItemsEl;
      this.init();
    } else {
      this.mutationObserver = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
          for (const node of mutation.addedNodes) {
            if (!isDocumentFragmentOrElementNode(node)) continue;
            if (node.classList.contains('overflow-menu-items')) {
              this.menuItemsEl = node;
              this.mutationObserver?.disconnect();
              this.init();
            }
          }
        }
      });
      this.mutationObserver.observe(this, {childList: true});
    }
  }

  disconnectedCallback() {
    this.mutationObserver?.disconnect();
    this.resizeObserver?.disconnect();
  }
});