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, () => { // eslint-disable-line unicorn/consistent-function-scoping 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(); } });