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  });