diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-12-12 23:57:56 +0100 |
commit | e68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch) | |
tree | 97775d6c13b0f416af55314eb6a89ef792474615 /web_src/js/webcomponents/overflow-menu.js | |
parent | Initial commit. (diff) | |
download | forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip |
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r-- | web_src/js/webcomponents/overflow-menu.js | 220 |
1 files changed, 220 insertions, 0 deletions
diff --git a/web_src/js/webcomponents/overflow-menu.js b/web_src/js/webcomponents/overflow-menu.js new file mode 100644 index 0000000..a69ce16 --- /dev/null +++ b/web_src/js/webcomponents/overflow-menu.js @@ -0,0 +1,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(); + } +}); |