aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/menu.js
blob: 12ca5843c6706aaf3e77b5ef1862c9c3deb6035d (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
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
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
 *   { label, icon?, loadItems, empty? }     the same, fetched when expanded
 *   { divider: true }
 *
 * `loadItems` exists for the one submenu whose contents are not already in
 * hand: a playlist's tracklist, which is read out of IndexedDB. Reading every
 * playlist's tracks to build a menu nobody may open would mean a ten-thousand
 * track list read on every click of the button.
 */

// 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, onResize }) {
  const [expanded, setExpanded] = useState(null);
  // index -> the items that came back, or 'loading'.
  const [loaded, setLoaded] = useState({});

  // A submenu opening, and a lazily-read one arriving, are the only two things
  // that change how tall the panel is — and `Menu` can see neither, because
  // both are state in here. So they are reported rather than watched for: an
  // observer on the panel would be an observer on the very box the callback
  // resizes, which is a loop the browser breaks by raising an error.
  useEffect(() => {
    if (onResize) onResize();
  }, [expanded, loaded, onResize]);

  const toggle = useCallback((i, it) => {
    if (expanded === i) { setExpanded(null); return; }
    setExpanded(i);
    if (!it.loadItems || loaded[i] !== undefined) return;
    setLoaded((prev) => ({ ...prev, [i]: 'loading' }));
    Promise.resolve(it.loadItems())
      .then((rows) => setLoaded((prev) => ({ ...prev, [i]: rows || [] })))
      // A submenu that cannot be filled renders as empty rather than as a
      // spinner nothing will ever replace.
      .catch(() => setLoaded((prev) => ({ ...prev, [i]: [] })));
  }, [expanded, loaded]);

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

      if (it.items || it.loadItems) {
        const open = expanded === i;
        const rows = it.items || loaded[i];
        const pending = rows === 'loading' || (it.loadItems && rows === undefined);
        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(); toggle(i, it); }}>
            ${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 && (pending
            ? html`<div class="ctx-menu-empty"
                style=${`padding-left: ${28 + depth * 14}px`}><span class="spinner"></span></div>`
            : (rows && rows.length
              ? html`<${MenuItems} items=${rows} depth=${depth + 1}
                  onClose=${onClose} onResize=${onResize} />`
              : 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);
  // Bumped by the tree below when it changes height; a dependency of the
  // measurement, and nothing else reads it.
  const [grew, setGrew] = useState(0);
  const remeasure = useCallback(() => setGrew((n) => n + 1), []);

  // Measure, then place. A menu opened near an edge has to flip rather than be
  // clipped, and how tall it is depends on its own items — so the numbers
  // cannot be written down, they have to be read.
  //
  // This is not the mutate-then-measure loop CLAUDE.md records. The height read
  // is `scrollHeight`, the content's, which the `max-height` written back does
  // not move; and `place` is not one of the dependencies. So the effect cannot
  // wake itself.
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    // Content plus the panel's own borders — `box-sizing` is `border-box`, so
    // those count against the `max-height` about to be set.
    const h = el.scrollHeight + (el.offsetHeight - el.clientHeight);
    const left = Math.max(MARGIN,
      Math.min(x, window.innerWidth - el.offsetWidth - MARGIN));
    const below = window.innerHeight - MARGIN - y;
    const above = y - MARGIN;
    // Fits below, or below is simply the roomier side: stay at the pointer and
    // scroll. Otherwise flip above it.
    const top = (h <= below || below >= above) ? y : Math.max(MARGIN, y - h);
    // And the panel may take exactly the room left below where it was just
    // put. The stylesheet's `max-height` is a floor: it says how tall the panel
    // may be and nothing about where its bottom lands, so a panel opened 300px
    // down the window ran 300px past the bottom of it — it scrolled, but its
    // last rows scrolled into a part of itself that is off the screen, which no
    // further scrolling brings back. That is "the last track cannot be reached".
    const maxHeight = Math.max(0, window.innerHeight - MARGIN - top);
    setPlace((prev) => ((prev && prev.left === left && prev.top === top
      && prev.maxHeight === maxHeight) ? prev : { left, top, maxHeight }));
    // `grew` is a submenu reporting that it opened. Without it the panel keeps
    // the placement measured for its collapsed self, and a two-row menu opened
    // low threads sixty tracks through the room that was free beneath it.
    // eslint-disable-next-line
  }, [x, y, items, grew]);

  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.
    //
    // But the panel also scrolls *itself*: a playlist's tracklist expands
    // inside it and is routinely taller than the window. `scroll` does not
    // bubble, which is why this listener is on the capture phase — and capture
    // is equally what makes it hear the panel's own scrolling, on the way
    // down. So the menu closed the instant it was scrolled, by wheel or by
    // dragging its own scrollbar, and the track being reached for could not be
    // reached at all. Ask where the scroll came from, not merely that one
    // happened.
    const onScroll = (e) => {
      if (ref.current && e.target instanceof Node && ref.current.contains(e.target)) return;
      onClose();
    };
    document.addEventListener('keydown', onKey);
    document.addEventListener('mousedown', onDown);
    window.addEventListener('scroll', onScroll, true);
    window.addEventListener('resize', onClose);
    return () => {
      document.removeEventListener('keydown', onKey);
      document.removeEventListener('mousedown', onDown);
      window.removeEventListener('scroll', onScroll, 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; max-height: ${place.maxHeight}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}
        onResize=${remeasure} />
    </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 };