code.gitea.io/gitea@v1.22.3/web_src/js/webcomponents/overflow-menu.js (about) 1 import {throttle} from 'throttle-debounce'; 2 import {createTippy} from '../modules/tippy.js'; 3 import {isDocumentFragmentOrElementNode} from '../utils/dom.js'; 4 import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg'; 5 6 window.customElements.define('overflow-menu', class extends HTMLElement { 7 updateItems = throttle(100, () => { 8 if (!this.tippyContent) { 9 const div = document.createElement('div'); 10 div.classList.add('tippy-target'); 11 div.tabIndex = -1; // for initial focus, programmatic focus only 12 div.addEventListener('keydown', (e) => { 13 if (e.key === 'Tab') { 14 const items = this.tippyContent.querySelectorAll('[role="menuitem"]'); 15 if (e.shiftKey) { 16 if (document.activeElement === items[0]) { 17 e.preventDefault(); 18 items[items.length - 1].focus(); 19 } 20 } else { 21 if (document.activeElement === items[items.length - 1]) { 22 e.preventDefault(); 23 items[0].focus(); 24 } 25 } 26 } else if (e.key === 'Escape') { 27 e.preventDefault(); 28 e.stopPropagation(); 29 this.button._tippy.hide(); 30 this.button.focus(); 31 } else if (e.key === ' ' || e.code === 'Enter') { 32 if (document.activeElement?.matches('[role="menuitem"]')) { 33 e.preventDefault(); 34 e.stopPropagation(); 35 document.activeElement.click(); 36 } 37 } else if (e.key === 'ArrowDown') { 38 if (document.activeElement?.matches('.tippy-target')) { 39 e.preventDefault(); 40 e.stopPropagation(); 41 document.activeElement.querySelector('[role="menuitem"]:first-of-type').focus(); 42 } else if (document.activeElement?.matches('[role="menuitem"]')) { 43 e.preventDefault(); 44 e.stopPropagation(); 45 document.activeElement.nextElementSibling?.focus(); 46 } 47 } else if (e.key === 'ArrowUp') { 48 if (document.activeElement?.matches('.tippy-target')) { 49 e.preventDefault(); 50 e.stopPropagation(); 51 document.activeElement.querySelector('[role="menuitem"]:last-of-type').focus(); 52 } else if (document.activeElement?.matches('[role="menuitem"]')) { 53 e.preventDefault(); 54 e.stopPropagation(); 55 document.activeElement.previousElementSibling?.focus(); 56 } 57 } 58 }); 59 this.append(div); 60 this.tippyContent = div; 61 } 62 63 const itemFlexSpace = this.menuItemsEl.querySelector('.item-flex-space'); 64 const itemOverFlowMenuButton = this.querySelector('.overflow-menu-button'); 65 66 // move items in tippy back into the menu items for subsequent measurement 67 for (const item of this.tippyItems || []) { 68 if (!itemFlexSpace || item.getAttribute('data-after-flex-space')) { 69 this.menuItemsEl.append(item); 70 } else { 71 itemFlexSpace.insertAdjacentElement('beforebegin', item); 72 } 73 } 74 75 // measure which items are partially outside the element and move them into the button menu 76 // flex space and overflow menu are excluded from measurement 77 itemFlexSpace?.style.setProperty('display', 'none', 'important'); 78 itemOverFlowMenuButton?.style.setProperty('display', 'none', 'important'); 79 this.tippyItems = []; 80 const menuRight = this.offsetLeft + this.offsetWidth; 81 const menuItems = this.menuItemsEl.querySelectorAll('.item, .item-flex-space'); 82 let afterFlexSpace = false; 83 for (const item of menuItems) { 84 if (item.classList.contains('item-flex-space')) { 85 afterFlexSpace = true; 86 continue; 87 } 88 if (afterFlexSpace) item.setAttribute('data-after-flex-space', 'true'); 89 const itemRight = item.offsetLeft + item.offsetWidth; 90 if (menuRight - itemRight < 38) { // roughly the width of .overflow-menu-button with some extra space 91 this.tippyItems.push(item); 92 } 93 } 94 itemFlexSpace?.style.removeProperty('display'); 95 itemOverFlowMenuButton?.style.removeProperty('display'); 96 97 // if there are no overflown items, remove any previously created button 98 if (!this.tippyItems?.length) { 99 const btn = this.querySelector('.overflow-menu-button'); 100 btn?._tippy?.destroy(); 101 btn?.remove(); 102 return; 103 } 104 105 // remove aria role from items that moved from tippy to menu 106 for (const item of menuItems) { 107 if (!this.tippyItems.includes(item)) { 108 item.removeAttribute('role'); 109 } 110 } 111 112 // move all items that overflow into tippy 113 for (const item of this.tippyItems) { 114 item.setAttribute('role', 'menuitem'); 115 this.tippyContent.append(item); 116 } 117 118 // update existing tippy 119 if (this.button?._tippy) { 120 this.button._tippy.setContent(this.tippyContent); 121 return; 122 } 123 124 // create button initially 125 const btn = document.createElement('button'); 126 btn.classList.add('overflow-menu-button'); 127 btn.setAttribute('aria-label', window.config.i18n.more_items); 128 btn.innerHTML = octiconKebabHorizontal; 129 this.append(btn); 130 this.button = btn; 131 132 createTippy(btn, { 133 trigger: 'click', 134 hideOnClick: true, 135 interactive: true, 136 placement: 'bottom-end', 137 role: 'menu', 138 theme: 'menu', 139 content: this.tippyContent, 140 onShow: () => { // FIXME: onShown doesn't work (never be called) 141 setTimeout(() => { 142 this.tippyContent.focus(); 143 }, 0); 144 }, 145 }); 146 }); 147 148 init() { 149 // for horizontal menus where fomantic boldens active items, prevent this bold text from 150 // enlarging the menu's active item replacing the text node with a div that renders a 151 // invisible pseudo-element that enlarges the box. 152 if (this.matches('.ui.secondary.pointing.menu, .ui.tabular.menu')) { 153 for (const item of this.querySelectorAll('.item')) { 154 for (const child of item.childNodes) { 155 if (child.nodeType === Node.TEXT_NODE) { 156 const text = child.textContent.trim(); // whitespace is insignificant inside flexbox 157 if (!text) continue; 158 const span = document.createElement('span'); 159 span.classList.add('resize-for-semibold'); 160 span.setAttribute('data-text', text); 161 span.textContent = text; 162 child.replaceWith(span); 163 } 164 } 165 } 166 } 167 168 // ResizeObserver triggers on initial render, so we don't manually call `updateItems` here which 169 // also avoids a full-page FOUC in Firefox that happens when `updateItems` is called too soon. 170 this.resizeObserver = new ResizeObserver((entries) => { 171 for (const entry of entries) { 172 const newWidth = entry.contentBoxSize[0].inlineSize; 173 if (newWidth !== this.lastWidth) { 174 requestAnimationFrame(() => { 175 this.updateItems(); 176 }); 177 this.lastWidth = newWidth; 178 } 179 } 180 }); 181 this.resizeObserver.observe(this); 182 } 183 184 connectedCallback() { 185 this.setAttribute('role', 'navigation'); 186 187 // check whether the mandatory `.overflow-menu-items` element is present initially which happens 188 // with Vue which renders differently than browsers. If it's not there, like in the case of browser 189 // template rendering, wait for its addition. 190 // The eslint rule is not sophisticated enough or aware of this problem, see 191 // https://github.com/43081j/eslint-plugin-wc/pull/130 192 const menuItemsEl = this.querySelector('.overflow-menu-items'); // eslint-disable-line wc/no-child-traversal-in-connectedcallback 193 if (menuItemsEl) { 194 this.menuItemsEl = menuItemsEl; 195 this.init(); 196 } else { 197 this.mutationObserver = new MutationObserver((mutations) => { 198 for (const mutation of mutations) { 199 for (const node of mutation.addedNodes) { 200 if (!isDocumentFragmentOrElementNode(node)) continue; 201 if (node.classList.contains('overflow-menu-items')) { 202 this.menuItemsEl = node; 203 this.mutationObserver?.disconnect(); 204 this.init(); 205 } 206 } 207 } 208 }); 209 this.mutationObserver.observe(this, {childList: true}); 210 } 211 } 212 213 disconnectedCallback() { 214 this.mutationObserver?.disconnect(); 215 this.resizeObserver?.disconnect(); 216 } 217 });