summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/menu.js
blob: 4aa08ca14376e04e6e4e4d5e8edda4e829bb8d6f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
import {
  html, useState, useEffect, useRef, useCallback,
} from './vendor/htm-preact.js';
import { Icon } from './icon.js';

/**
 * A pop-up menu, opened at a point — the shared component the media views use
 * for "what do you want to do with this album" (docs/playlists.md §10.1).
 *
 * Two affordances, both always present, because neither covers everyone:
 * right-click is what a desktop reader reaches for and leaves no chrome on a
 * grid of covers, and a dots button is the only thing that exists on a
 * touchscreen. `openAt` takes either event and needs to know which.
 *
 * **Submenus expand in place** rather than flying out sideways. The account
 * menu's language list already does this (`app.js`), and a flyout is the one
 * shape that cannot work here: the longest submenu is a playlist's tracklist,
 * and at a phone's 400px there is nowhere for a second panel to go. Expanding
 * downward needs no flipping, no hover intent, and no separate mobile design.
 *
 * `items` is a flat list, each entry one of:
 *   { label, icon?, onSelect }          an action
 *   { label, icon?, items, empty? }     a submenu, expanded in place
 *   { divider: true }
 */

// Kept away from the viewport edges; `.ctx-menu` sets the width this assumes.
const MARGIN = 8;

function useMenu() {
  const [menu, setMenu] = useState(null); // { x, y, items }

  const close = useCallback(() => setMenu(null), []);

  /**
   * Open at `e`. A `contextmenu` event carries the pointer; a click on a dots
   * button carries only the button, so the menu hangs off its bottom-right —
   * read synchronously, because `currentTarget` is null once the handler has
   * returned.
   */
  const openAt = useCallback((e, items) => {
    e.preventDefault();
    e.stopPropagation();
    if (e.type === 'contextmenu') {
      setMenu({ x: e.clientX, y: e.clientY, items });
      return;
    }
    const r = e.currentTarget.getBoundingClientRect();
    setMenu({ x: r.right, y: r.bottom + 4, items });
  }, []);

  return { menu, openAt, close };
}

function MenuItems({ items, depth, onClose }) {
  const [expanded, setExpanded] = useState(null);

  return html`
    ${items.map((it, i) => {
      if (it.divider) return html`<div class="ctx-menu-divider" key=${`d${i}`}></div>`;

      if (it.items) {
        const open = expanded === i;
        return html`
          <button class="ctx-menu-item" key=${it.key || it.label}
            style=${depth ? `padding-left: ${14 + depth * 14}px` : null}
            onClick=${(e) => { e.stopPropagation(); setExpanded(open ? null : i); }}>
            ${it.icon && html`<${Icon} name=${it.icon} cls="ctx-menu-icon" />`}
            <span class="ctx-menu-label">${it.label}</span>
            <${Icon} name="chevron" cls="ctx-menu-caret ${open ? 'flip' : ''}" />
          </button>
          ${open && (it.items.length
            ? html`<${MenuItems} items=${it.items} depth=${depth + 1} onClose=${onClose} />`
            : html`<div class="ctx-menu-empty"
                style=${`padding-left: ${28 + depth * 14}px`}>${it.empty || ''}</div>`)}
        `;
      }

      return html`
        <button class="ctx-menu-item ${it.danger ? 'danger' : ''}" key=${it.key || it.label}
          style=${depth ? `padding-left: ${14 + depth * 14}px` : null}
          disabled=${!!it.disabled}
          onClick=${() => {
            // Closed first: an action that opens a dialog of its own must not
            // leave this hanging over it.
            onClose();
            if (it.onSelect) it.onSelect();
          }}>
          ${it.icon && html`<${Icon} name=${it.icon} cls="ctx-menu-icon" />`}
          <span class="ctx-menu-label">${it.label}</span>
          ${it.hint && html`<span class="ctx-menu-hint">${it.hint}</span>`}
        </button>
      `;
    })}
  `;
}

function Menu({ x, y, items, onClose }) {
  const ref = useRef(null);
  const [place, setPlace] = useState(null);

  // Measure, then place. A menu opened near the right or bottom edge has to
  // flip rather than be clipped, and how tall it is depends on its own items —
  // so the number cannot be written down, it has to be read.
  //
  // This is not the mutate-then-measure loop CLAUDE.md records: the effect
  // writes `place`, which moves the panel, and `place` is not in its
  // dependencies. Position does not change the size being measured, so it
  // cannot wake itself.
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    const left = Math.max(MARGIN, Math.min(x, window.innerWidth - r.width - MARGIN));
    const top = (y + r.height > window.innerHeight - MARGIN)
      ? Math.max(MARGIN, y - r.height)
      : y;
    setPlace({ left, top });
    // eslint-disable-next-line
  }, [x, y, items]);

  useEffect(() => {
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    // `mousedown`, not `click`: the click that follows the right-click which
    // opened this would otherwise close it again before anything is drawn.
    const onDown = (e) => {
      if (ref.current && !ref.current.contains(e.target)) onClose();
    };
    // Scroll closes it, and this one is not optional. The media toolbars are
    // sticky bands and the grid scrolls underneath them, so a menu that
    // survives a scroll is a menu still pointing at the album it was opened
    // on while sitting over a completely different one.
    document.addEventListener('keydown', onKey);
    document.addEventListener('mousedown', onDown);
    window.addEventListener('scroll', onClose, true);
    window.addEventListener('resize', onClose);
    return () => {
      document.removeEventListener('keydown', onKey);
      document.removeEventListener('mousedown', onDown);
      window.removeEventListener('scroll', onClose, true);
      window.removeEventListener('resize', onClose);
    };
  }, [onClose]);

  return html`
    <div class="ctx-menu" ref=${ref} role="menu"
      style=${place
        ? `left: ${place.left}px; top: ${place.top}px`
        // Drawn where asked but not yet shown, for the one frame it takes to
        // find out how big it is — otherwise it visibly jumps into place.
        : `left: ${x}px; top: ${y}px; visibility: hidden`}>
      <${MenuItems} items=${items} depth=${0} onClose=${onClose} />
    </div>
  `;
}

/**
 * The dots button. A separate export because every call site needs the same
 * one and because it must be a real `<button>` sibling of the row's own
 * action, never nested inside it — see `music-app.js`.
 */
function MenuDots({ onOpen, title, cls = '' }) {
  return html`
    <button class="ctx-dots ${cls}" title=${title || ''} aria-haspopup="menu"
      onClick=${onOpen}>
      <${Icon} name="dots" />
    </button>
  `;
}

export { Menu, MenuDots, useMenu };