mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-11-03 08:02:36 +09:00 
			
		
		
		
	1. Add `<overflow-menu>` web component 2. Rename `<gitea-origin-url>` to `<origin-url>` and make filenames match. <img width="439" alt="image" src="https://github.com/go-gitea/gitea/assets/115237/2fbe4ca4-110b-4ad2-8e17-c1e116ccbd74"> <img width="444" alt="Screenshot 2024-03-02 at 21 36 52" src="https://github.com/go-gitea/gitea/assets/115237/aa8f786e-dc8c-4030-b12d-7cfb74bdfd6e"> <img width="537" alt="Screenshot 2024-03-03 at 03 05 06" src="https://github.com/go-gitea/gitea/assets/115237/fddd50aa-adf1-4b4b-bd7f-caf30c7b2245">   TODO: - [x] Check if removal of `requestAnimationFrame` is possible to avoid flash of content. Likely needs a `MutationObserver`. - [x] Hide tippy when button is removed from DOM. - [x] ~~Implement right-aligned items (https://github.com/go-gitea/gitea/pull/28976)~~. Not going to do it. - [x] Clean up CSS so base element has no background and add background via tailwind instead. - [x] Use it for org and user page. --------- Co-authored-by: Giteabot <teabot@gitea.io> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
		
			
				
	
	
		
			180 lines
		
	
	
		
			6.4 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			180 lines
		
	
	
		
			6.4 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
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');
 | 
						|
    for (const item of menuItems) {
 | 
						|
      const itemRight = item.offsetLeft + item.offsetWidth;
 | 
						|
      if (menuRight - itemRight < 38) { // roughly the width of .overflow-menu-button
 | 
						|
        this.tippyItems.push(item);
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    // 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() {
 | 
						|
    // 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();
 | 
						|
  }
 | 
						|
});
 |