github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/web/menu.ts (about)

     1  // Copyright 2022 Daniel Erat.
     2  // All rights reserved.
     3  
     4  import {
     5    commonStyles,
     6    createElement,
     7    createShadow,
     8    createTemplate,
     9  } from './common.js';
    10  
    11  const menuStyle = createTemplate(`
    12  <style>
    13    .item {
    14      cursor: default;
    15      padding: 6px 12px;
    16      user-select: none;
    17    }
    18    .item:hover {
    19      background-color: var(--menu-hover-color);
    20    }
    21    .item:first-child {
    22      padding-top: 8px;
    23    }
    24    .item:last-child {
    25      padding-bottom: 8px;
    26    }
    27    .item .hotkey {
    28      color: var(--text-label-color);
    29      display: inline-block;
    30      float: right;
    31      margin-left: var(--margin);
    32      text-align: right;
    33      min-width: 50px;
    34    }
    35    hr {
    36      background-color: var(--border-color);
    37      border: 0;
    38      height: 1px;
    39      margin: 4px 0;
    40    }
    41  </style>
    42  `);
    43  
    44  // Number of open menus.
    45  let numMenus = 0;
    46  
    47  // Creates and displays a simple context menu at the specified location.
    48  // Returns a <dialog> element containing a <span> acting as a shadow root.
    49  export function createMenu(
    50    x: number,
    51    y: number,
    52    items: MenuItem[],
    53    alignRight = false
    54  ) {
    55    const menu = createElement(
    56      'dialog',
    57      'menu',
    58      document.body
    59    ) as HTMLDialogElement;
    60    menu.addEventListener('close', () => {
    61      document.body.removeChild(menu);
    62      numMenus--;
    63    });
    64    menu.addEventListener('click', (e) => {
    65      const rect = menu.getBoundingClientRect();
    66      if (
    67        e.clientX < rect.left ||
    68        e.clientX > rect.right ||
    69        e.clientY < rect.top ||
    70        e.clientY > rect.bottom
    71      ) {
    72        menu.close();
    73      }
    74    });
    75  
    76    // It seems like it isn't possible to attach a shadow root directly to
    77    // <dialog>, so add a wrapper element first.
    78    const wrapper = createElement('span', null, menu);
    79    const shadow = createShadow(wrapper, menuStyle);
    80    shadow.adoptedStyleSheets = [commonStyles];
    81  
    82    const hotkeys = items.some((it) => it.hotkey);
    83    for (const item of items) {
    84      if (item.text === '-') {
    85        createElement('hr', null, shadow);
    86      } else {
    87        const el = createElement('div', 'item', shadow, item.text);
    88        if (item.id) el.id = item.id;
    89        if (hotkeys) createElement('span', 'hotkey', el, item.hotkey ?? '');
    90        el.addEventListener('click', (e) => {
    91          e.stopPropagation();
    92          menu.close();
    93          if (item.cb) item.cb();
    94        });
    95      }
    96    }
    97  
    98    // For some reason, the menu's clientWidth and clientHeight seem to initially
    99    // be 0 after switching to <dialog>. Deferring the calculation of the menu's
   100    // position seems to work around this.
   101    window.setTimeout(() => {
   102      if (alignRight) {
   103        menu.style.right = `${x}px`;
   104      } else {
   105        // Keep the menu onscreen.
   106        menu.style.left =
   107          x + menu.clientWidth <= window.innerWidth
   108            ? `${x}px`
   109            : `${x - menu.clientWidth}px`;
   110      }
   111      menu.style.top =
   112        y + menu.clientHeight <= window.innerHeight
   113          ? `${y}px`
   114          : `${y - menu.clientHeight}px`;
   115  
   116      // Only show the menu after it's been positioned.
   117      menu.classList.add('ready');
   118    });
   119  
   120    numMenus++;
   121    menu.showModal();
   122    return menu;
   123  }
   124  
   125  interface MenuItem {
   126    text: string; // menu item text, or '-' to insert separator instead
   127    cb?: () => void; // callback to run when clicked
   128    id?: string; // ID for element (used in tests)
   129    hotkey?: string; // text describing menu's accelerator
   130  }
   131  
   132  // Returns true if a menu is currently shown.
   133  export const isMenuShown = () => numMenus > 0;