summaryrefslogtreecommitdiffstats
path: root/web_src/js/webcomponents/overflow-menu.js
diff options
context:
space:
mode:
Diffstat (limited to 'web_src/js/webcomponents/overflow-menu.js')
-rw-r--r--web_src/js/webcomponents/overflow-menu.js220
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();
+ }
+});