flexdesk 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2261 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/css/base.css +1834 -75
  6. package/css/flexdesk.css +3373 -238
  7. package/css/overrides.css +16 -0
  8. package/css/reset.css +0 -0
  9. package/css/tokens.css +0 -0
  10. package/dist/canvas.js +6 -239
  11. package/dist/canvas.js.map +3 -3
  12. package/dist/charts.js +709 -18
  13. package/dist/charts.js.map +4 -4
  14. package/dist/chunk-3PHCPZHT.js +0 -0
  15. package/dist/chunk-3PHCPZHT.js.map +0 -0
  16. package/dist/chunk-4A7SJC3T.js +503 -0
  17. package/dist/chunk-4A7SJC3T.js.map +7 -0
  18. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  19. package/dist/chunk-CVMCZZX2.js.map +7 -0
  20. package/dist/chunk-D3COLZZ2.js +1311 -0
  21. package/dist/chunk-D3COLZZ2.js.map +7 -0
  22. package/dist/chunk-FOOS3T5L.js +0 -0
  23. package/dist/chunk-FOOS3T5L.js.map +0 -0
  24. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  25. package/dist/chunk-GOQLR5WR.js.map +7 -0
  26. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  27. package/dist/chunk-HNU6MM2B.js.map +7 -0
  28. package/dist/chunk-HYJYYEPK.js +375 -0
  29. package/dist/chunk-HYJYYEPK.js.map +7 -0
  30. package/dist/chunk-JKZLNKG6.js +2041 -0
  31. package/dist/chunk-JKZLNKG6.js.map +7 -0
  32. package/dist/chunk-JYWURG5T.js +0 -0
  33. package/dist/chunk-JYWURG5T.js.map +0 -0
  34. package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
  35. package/dist/chunk-MCIXFNVF.js.map +7 -0
  36. package/dist/chunk-N7HC3ADV.js +30 -0
  37. package/dist/chunk-N7HC3ADV.js.map +7 -0
  38. package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
  39. package/dist/chunk-Q36EPT2R.js.map +7 -0
  40. package/dist/chunk-QWQJUDPD.js +610 -0
  41. package/dist/chunk-QWQJUDPD.js.map +7 -0
  42. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  43. package/dist/chunk-UURZ37SD.js.map +7 -0
  44. package/dist/chunk-UWO43BZU.js +111 -0
  45. package/dist/chunk-UWO43BZU.js.map +7 -0
  46. package/dist/chunk-WVFGV5FT.js +0 -0
  47. package/dist/chunk-WVFGV5FT.js.map +0 -0
  48. package/dist/core.js +170 -118
  49. package/dist/core.js.map +2 -2
  50. package/dist/editor.js +156 -70
  51. package/dist/editor.js.map +2 -2
  52. package/dist/flexdesk.css +3373 -238
  53. package/dist/flow.js +2789 -306
  54. package/dist/flow.js.map +4 -4
  55. package/dist/host.js +109 -3
  56. package/dist/host.js.map +3 -3
  57. package/dist/reset.css +0 -0
  58. package/dist/tile_registry-6WZPTUZV.js +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  60. package/dist/tiles.js +1131 -253
  61. package/dist/tiles.js.map +4 -4
  62. package/dist/tokens.css +0 -0
  63. package/dist/widgets.js +3254 -2112
  64. package/dist/widgets.js.map +4 -4
  65. package/dist/wm.js +1227 -345
  66. package/dist/wm.js.map +4 -4
  67. package/package.json +3 -1
  68. package/src/canvas/canvas_adapter.js +5 -0
  69. package/src/canvas/edge_router.js +0 -0
  70. package/src/canvas/graph/connector_router.js +0 -0
  71. package/src/canvas/graph/history_service.js +0 -0
  72. package/src/canvas/graph/node_platform.js +0 -0
  73. package/src/canvas/index.js +1 -0
  74. package/src/canvas/layered_layout.js +244 -0
  75. package/src/charts/chart_types.js +0 -0
  76. package/src/charts/data_series_plot_window.js +0 -0
  77. package/src/charts/downsample.js +2 -2
  78. package/src/charts/plot_config.js +53 -29
  79. package/src/charts/plot_config_editor.js +627 -0
  80. package/src/charts/plot_grid.js +449 -0
  81. package/src/charts/plot_popout_window.js +103 -317
  82. package/src/charts/plotly_wrapper.js +22 -2
  83. package/src/charts/series_transforms.js +206 -0
  84. package/src/core/event_bus.js +0 -0
  85. package/src/core/logging.js +1 -1
  86. package/src/core/settings.js +105 -19
  87. package/src/core/state_guard.js +93 -51
  88. package/src/core/state_guard_config.js +85 -74
  89. package/src/core/state_machine.js +0 -0
  90. package/src/editor/editor_pane.js +3 -3
  91. package/src/editor/monaco_editor_factory.js +124 -60
  92. package/src/editor/monaco_loader.js +103 -28
  93. package/src/editor/notebook_search.js +0 -0
  94. package/src/editor/notebook_tab_bar.js +38 -52
  95. package/src/editor/notebook_undo_manager.js +0 -0
  96. package/src/editor/split_pane_container.js +6 -3
  97. package/src/flow/canvas_editor/connections.js +90 -0
  98. package/src/flow/canvas_editor/editor.js +2351 -153
  99. package/src/flow/canvas_editor/index.js +2 -1
  100. package/src/flow/canvas_editor/layout.js +119 -12
  101. package/src/flow/canvas_editor/strings.js +80 -0
  102. package/src/flow/kit/always_before.js +0 -0
  103. package/src/flow/kit/catalogue.js +97 -3
  104. package/src/flow/kit/chip_input.js +0 -0
  105. package/src/flow/kit/dom.js +0 -0
  106. package/src/flow/kit/findings.js +0 -0
  107. package/src/flow/kit/history.js +0 -0
  108. package/src/flow/kit/index.js +3 -3
  109. package/src/flow/kit/keys.js +7 -1
  110. package/src/flow/kit/logic_graph.js +97 -15
  111. package/src/flow/kit/references.js +0 -0
  112. package/src/flow/kit/settings_panel.js +0 -0
  113. package/src/flow/kit/settings_schema.js +2 -1
  114. package/src/flow/kit/step_picker.js +0 -0
  115. package/src/flow/kit/strings.js +3 -1
  116. package/src/flow/kit/value_picker.js +0 -0
  117. package/src/flow/kit/widgets.js +86 -109
  118. package/src/flow/lanes/editor.js +136 -14
  119. package/src/flow/lanes/index.js +3 -1
  120. package/src/flow/lanes/layout.js +0 -0
  121. package/src/flow/lanes/pipeline.js +44 -2
  122. package/src/flow/lanes/preview.js +0 -0
  123. package/src/flow/lanes/strings.js +1 -0
  124. package/src/flow/outline/editor.js +0 -0
  125. package/src/flow/outline/index.js +0 -0
  126. package/src/flow/outline/mapping.js +0 -0
  127. package/src/flow/outline/menu.js +0 -0
  128. package/src/flow/outline/operations.js +0 -0
  129. package/src/flow/outline/recognise.js +0 -0
  130. package/src/flow/outline/strings.js +0 -0
  131. package/src/help/help_modal.js +168 -42
  132. package/src/help/help_registry.js +12 -1
  133. package/src/host/external_links.js +0 -0
  134. package/src/host/file_picker.js +117 -0
  135. package/src/host/host.js +10 -2
  136. package/src/host/photino_host.js +9 -0
  137. package/src/host/photino_host.md +21 -3
  138. package/src/host/pywebview_host.js +10 -0
  139. package/src/tiles/chart_tiles.js +53 -245
  140. package/src/tiles/config_schema.js +8 -0
  141. package/src/tiles/figure_tile.js +322 -0
  142. package/src/tiles/layout_persistence.js +3 -3
  143. package/src/tiles/list_tiles.js +422 -0
  144. package/src/tiles/scope_picker.js +96 -0
  145. package/src/tiles/tile_base.js +12 -1
  146. package/src/tiles/tile_config_form.js +442 -0
  147. package/src/tiles/tile_grid.js +38 -1
  148. package/src/tiles/tile_registry.js +0 -0
  149. package/src/tiling/bottom_panel_tabs.js +67 -99
  150. package/src/tiling/command_palette.js +64 -19
  151. package/src/tiling/content_registry.js +4 -1
  152. package/src/tiling/desktops.js +11 -1
  153. package/src/tiling/entity_sources.js +0 -0
  154. package/src/tiling/keymap.js +403 -128
  155. package/src/tiling/kind_taxonomy.js +78 -0
  156. package/src/tiling/landing_table.js +0 -0
  157. package/src/tiling/loading_overlay.js +0 -0
  158. package/src/tiling/nav_panel.js +59 -10
  159. package/src/tiling/page_factory.js +5 -5
  160. package/src/tiling/panel_availability.js +49 -0
  161. package/src/tiling/panel_keys.js +0 -0
  162. package/src/tiling/shell.js +114 -12
  163. package/src/tiling/tab_strip.js +0 -0
  164. package/src/tiling/tile_breadcrumb.js +0 -0
  165. package/src/tiling/tile_renderer.js +105 -9
  166. package/src/tiling/tile_tab_menu.js +0 -0
  167. package/src/tiling/tile_tree.js +0 -0
  168. package/src/tiling/wm.js +562 -30
  169. package/src/tiling/zoom.js +0 -0
  170. package/src/ui/base/component_base.js +0 -0
  171. package/src/ui/base/controller_base.js +0 -0
  172. package/src/ui/base/page_base.js +0 -0
  173. package/src/ui/components/about_dialog.js +0 -0
  174. package/src/ui/components/action_dropdown.js +30 -2
  175. package/src/ui/components/alert_log.js +0 -0
  176. package/src/ui/components/alerts_pane.js +0 -0
  177. package/src/ui/components/attribute_list_editor.js +0 -0
  178. package/src/ui/components/autocomplete_field.js +0 -0
  179. package/src/ui/components/cell_editor_host.js +211 -0
  180. package/src/ui/components/cell_grid.js +648 -0
  181. package/src/ui/components/cell_selection.js +220 -0
  182. package/src/ui/components/choice_controls.js +351 -0
  183. package/src/ui/components/color_field.js +211 -0
  184. package/src/ui/components/color_list_field.js +210 -0
  185. package/src/ui/components/column_fit.js +0 -0
  186. package/src/ui/components/column_groups.js +314 -0
  187. package/src/ui/components/column_order.js +142 -0
  188. package/src/ui/components/computing_status_window.js +98 -25
  189. package/src/ui/components/confirm_dialog.js +81 -38
  190. package/src/ui/components/context_menu.js +2 -2
  191. package/src/ui/components/conversation_view.js +440 -0
  192. package/src/ui/components/crosstab.js +289 -0
  193. package/src/ui/components/data_table.js +601 -62
  194. package/src/ui/components/detail_header.js +22 -11
  195. package/src/ui/components/document_outline.js +397 -0
  196. package/src/ui/components/drag_reorder.js +0 -0
  197. package/src/ui/components/fact_table.js +0 -0
  198. package/src/ui/components/fill_handle.js +322 -0
  199. package/src/ui/components/gallery_picker.js +19 -8
  200. package/src/ui/components/header_actions.js +55 -0
  201. package/src/ui/components/inline_renamer.js +0 -0
  202. package/src/ui/components/job_progress.js +51 -0
  203. package/src/ui/components/managed_window.js +27 -4
  204. package/src/ui/components/menu_bar.js +745 -0
  205. package/src/ui/components/modal.js +449 -75
  206. package/src/ui/components/notification_history.js +3 -3
  207. package/src/ui/components/progress.js +483 -0
  208. package/src/ui/components/record_list.js +0 -0
  209. package/src/ui/components/row_activation.js +0 -0
  210. package/src/ui/components/section_stack.js +298 -0
  211. package/src/ui/components/series.js +173 -0
  212. package/src/ui/components/settings_view.js +634 -0
  213. package/src/ui/components/slide_out_panel.js +43 -6
  214. package/src/ui/components/slider_field.js +275 -18
  215. package/src/ui/components/sortable_list.js +26 -4
  216. package/src/ui/components/staged_edits.js +343 -0
  217. package/src/ui/components/staged_history.js +172 -0
  218. package/src/ui/components/table_filter_chips.js +0 -0
  219. package/src/ui/components/table_state_store.js +0 -0
  220. package/src/ui/components/table_structure.js +182 -0
  221. package/src/ui/components/table_values.js +0 -0
  222. package/src/ui/components/tabs.js +497 -0
  223. package/src/ui/components/tag_input.js +215 -0
  224. package/src/ui/components/toast.js +25 -8
  225. package/src/ui/components/toggle_field.js +175 -0
  226. package/src/ui/components/tree_view.js +55 -34
  227. package/src/ui/components/window_taskbar.js +0 -0
  228. package/src/ui/components/wizard.js +563 -0
  229. package/src/ui/controllers/window_chrome_controller.js +2 -2
  230. package/src/ui/notification_center.js +375 -119
  231. package/src/ui/utils/auto_scrollbars.js +0 -0
  232. package/src/ui/utils/markdown.js +709 -0
  233. package/src/ui/utils/native_context_menu.js +0 -0
  234. package/src/ui/utils/overlay_scrollbar.js +16 -12
  235. package/src/ui/utils/raf_resize_observer.js +0 -0
  236. package/src/ui/utils/scroll_lock.js +0 -0
  237. package/src/ui/utils/strip_scroll.js +55 -0
  238. package/src/ui/utils/tooltip_service.js +0 -8
  239. package/dist/chunk-6JSOVNID.js.map +0 -7
  240. package/dist/chunk-CWPMLX67.js +0 -475
  241. package/dist/chunk-CWPMLX67.js.map +0 -7
  242. package/dist/chunk-FBGP5KN5.js.map +0 -7
  243. package/dist/chunk-FL5KFNQH.js.map +0 -7
  244. package/dist/chunk-NLTOY57M.js.map +0 -7
  245. package/dist/chunk-NNFTETK3.js +0 -633
  246. package/dist/chunk-NNFTETK3.js.map +0 -7
  247. package/dist/chunk-O5OHMWBB.js +0 -284
  248. package/dist/chunk-O5OHMWBB.js.map +0 -7
  249. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  250. package/dist/chunk-SN4LSOAR.js.map +0 -7
  251. package/src/ui/controllers/panel_state_machine.js +0 -634
@@ -0,0 +1,745 @@
1
+ /**
2
+ * menu_bar.js — a desktop menu bar, driven by a declarative model (STORY-0030).
3
+ *
4
+ * const bar = createMenuBar(el, {
5
+ * menus: [
6
+ * { id: 'file', label: 'File', items: [
7
+ * { id: 'open', label: 'Open…', shortcut: 'Ctrl+O' },
8
+ * { id: 'recent', label: 'Open recent', submenu: () => recentItems() },
9
+ * { separator: true },
10
+ * { id: 'save', label: 'Save', shortcut: 'Ctrl+S', disabled: true },
11
+ * ] },
12
+ * { id: 'view', label: 'View', items: [
13
+ * { id: 'grid', label: 'Show grid', checked: true }, // a check item
14
+ * { id: 'light', label: 'Light', radio: 'theme', checked: true }, // a radio group
15
+ * { id: 'dark', label: 'Dark', radio: 'theme', checked: false },
16
+ * ] },
17
+ * ],
18
+ * onAction: (id, { item, checked }) => { … },
19
+ * });
20
+ *
21
+ * ── The model ─────────────────────────────────────────────────────────
22
+ * A menu is `{ id, label, items }`. An item is `{ id, label, shortcut?,
23
+ * disabled?, checked?, radio?, submenu? }`, or `{ separator: true }`.
24
+ * checked a boolean makes it a CHECK item (`menuitemcheckbox`); with
25
+ * `radio: '<group>'` it is a RADIO item (`menuitemradio`), and
26
+ * checking one unchecks the rest of its group.
27
+ * submenu an array of items, or a FUNCTION returning one. A function is
28
+ * called every time the submenu opens, so a list that changes
29
+ * under the bar (recent files, open windows) is always current.
30
+ * shortcut drawn on the right. It is a label: the bar binds no key for it.
31
+ *
32
+ * `onAction(id, { item, checked })` is called for an enabled leaf item —
33
+ * never for a disabled one, a separator, or an item that opens a submenu. A
34
+ * check or radio item has already flipped when it is called; `checked` is
35
+ * the new state.
36
+ *
37
+ * ── The keyboard ──────────────────────────────────────────────────────
38
+ * Alt (pressed and released on its own) or F10 focuses the bar, and pressing
39
+ * either again leaves it. On a title: Left/Right move across the titles,
40
+ * Down/Enter/Space open its menu on the first item, Up on the last. In a
41
+ * menu: Up/Down/Home/End move within it, skipping separators and disabled
42
+ * items; Right opens a submenu, or moves to the next menu; Left closes a
43
+ * submenu, or moves to the previous menu; Enter/Space activate. Esc closes
44
+ * one level at a time, and the Esc that leaves the bar gives focus back to
45
+ * whatever had it before the bar did. Activating an item gives it back too,
46
+ * BEFORE `onAction` runs, so a command acts on the context the user was in.
47
+ *
48
+ * ── The pointer ───────────────────────────────────────────────────────
49
+ * Clicking a title opens its menu (and closes it again); while a menu is
50
+ * open, moving over another title switches to it. Moving over an item with a
51
+ * submenu opens it. A press outside the bar closes everything.
52
+ *
53
+ * ── State lives here, not in the DOM ──────────────────────────────────
54
+ * `setEnabled(id, bool)` and `setChecked(id, bool)` record an override for
55
+ * that id, which wins over what the model says from then on — including over
56
+ * a function submenu's fresh items — and patch any open menu IN PLACE: the
57
+ * bar is not rebuilt, and focus stays where it was. A radio group keeps ONE
58
+ * recorded selection, so it shows exactly one checked item however its menu
59
+ * is reached — opened later, a function submenu included. To find an id the
60
+ * bar calls function submenus (as `commands()` does), open or not.
61
+ * `commands()` returns the same model as command-palette rows (`{ id, label,
62
+ * shortcut, context, action }`), so the palette and the bar list identical
63
+ * verbs.
64
+ *
65
+ * ── Roles ─────────────────────────────────────────────────────────────
66
+ * The WAI-ARIA menubar pattern: `role="menubar"` on the element you pass,
67
+ * a `role="menuitem"` title per menu inside a `role="none"` wrapper, each
68
+ * open menu a `role="menu"` beside its title (or its parent item), items
69
+ * `menuitem` / `menuitemcheckbox` / `menuitemradio` with `aria-checked`,
70
+ * `aria-disabled` and, on a submenu's item, `aria-haspopup`/`aria-expanded`.
71
+ * One title is in the tab order at a time (a roving tabindex).
72
+ *
73
+ * Every listener outside the bar is on the element's own document, and
74
+ * `dispose()` takes them off.
75
+ */
76
+
77
+ const SEL_TITLE = '.twm-menubar__title';
78
+ const SEL_ITEM = '.twm-menubar__item';
79
+ const SEL_MENU = '.twm-menubar__menu';
80
+
81
+ /** @returns {MenuBar} */
82
+ export function createMenuBar(el, opts) {
83
+ return new MenuBar(el, opts);
84
+ }
85
+
86
+ export class MenuBar {
87
+ /**
88
+ * @param {Element} el the element that becomes the bar
89
+ * @param {object} opts
90
+ * @param {Array} opts.menus `[{ id, label, items }]`
91
+ * @param {Function} [opts.onAction] `(id, { item, checked }) => void`
92
+ * @param {string} [opts.label] the bar's accessible name ('Menu')
93
+ * @param {string} [opts.emptyLabel] the disabled row an empty submenu shows ('Empty')
94
+ */
95
+ constructor(el, { menus, onAction = null, label = 'Menu', emptyLabel = 'Empty' } = {}) {
96
+ if (!el || typeof el.appendChild !== 'function') {
97
+ throw new TypeError('createMenuBar: `el` must be an element');
98
+ }
99
+ _validateMenus(menus);
100
+ this.el = el;
101
+ this.menus = menus;
102
+ this._onAction = typeof onAction === 'function' ? onAction : null;
103
+ this._emptyLabel = emptyLabel;
104
+ this._doc = el.ownerDocument;
105
+ this._disabled = new Map(); // id -> boolean (override)
106
+ this._checked = new Map(); // id -> boolean (override, check items)
107
+ this._radio = new Map(); // radio group -> the checked id (or null: none)
108
+ this._models = new WeakMap(); // item element -> model item
109
+ this._stack = []; // open menus: [{ menu, parent }]
110
+ this._openIdx = -1; // which title's menu is open
111
+ this._active = false; // focus is in the bar, or a menu is open
112
+ this._returnTo = null; // what had focus before the bar did
113
+ this._altArmed = false;
114
+
115
+ el.innerHTML = '';
116
+ el.classList.add('twm-menubar');
117
+ el.setAttribute('role', 'menubar');
118
+ el.setAttribute('aria-label', label);
119
+ this._titles = menus.map((m, i) => {
120
+ const entry = this._doc.createElement('div');
121
+ entry.className = 'twm-menubar__entry';
122
+ entry.setAttribute('role', 'none');
123
+ const btn = this._doc.createElement('button');
124
+ btn.type = 'button';
125
+ btn.className = 'twm-menubar__title';
126
+ btn.setAttribute('role', 'menuitem');
127
+ btn.setAttribute('aria-haspopup', 'menu');
128
+ btn.setAttribute('aria-expanded', 'false');
129
+ btn.tabIndex = i === 0 ? 0 : -1;
130
+ btn.textContent = m.label;
131
+ entry.appendChild(btn);
132
+ el.appendChild(entry);
133
+ return btn;
134
+ });
135
+
136
+ this._h = {
137
+ mousedown: (e) => this._onMouseDown(e),
138
+ click: (e) => this._onClick(e),
139
+ mouseover: (e) => this._onMouseOver(e),
140
+ keydown: (e) => this._onKey(e),
141
+ keyup: (e) => { if (e.key === ' ' && this.el.contains(e.target)) e.preventDefault(); },
142
+ focusin: (e) => this._onFocusIn(e),
143
+ focusout: (e) => this._onFocusOut(e),
144
+ docKeydown: (e) => this._onDocKeyDown(e),
145
+ docKeyup: (e) => this._onDocKeyUp(e),
146
+ docMousedown: (e) => this._onDocMouseDown(e),
147
+ // Alt+Tab, Alt+F4, Alt+Space: the second key goes to the OS, so no
148
+ // keydown reaches the page to disarm Alt. The window losing focus
149
+ // does, or the Alt keyup on return would take focus to the bar.
150
+ winBlur: () => { this._altArmed = false; },
151
+ };
152
+ for (const k of ['mousedown', 'click', 'mouseover', 'keydown', 'keyup', 'focusin', 'focusout']) {
153
+ el.addEventListener(k, this._h[k]);
154
+ }
155
+ this._doc.addEventListener('keydown', this._h.docKeydown, true);
156
+ this._doc.addEventListener('keyup', this._h.docKeyup, true);
157
+ this._doc.addEventListener('mousedown', this._h.docMousedown, true);
158
+ this._doc.defaultView?.addEventListener('blur', this._h.winBlur);
159
+ }
160
+
161
+ // ── public ────────────────────────────────────────────────────────
162
+
163
+ /** Whether a menu is open. */
164
+ isOpen() { return this._openIdx >= 0; }
165
+
166
+ /** Open the menu with this id (focus on its title, or on its first item). */
167
+ open(menuId, { focusItem = false } = {}) {
168
+ const i = this.menus.findIndex((m) => m.id === menuId);
169
+ if (i < 0) return;
170
+ this._enter();
171
+ this._open(i, focusItem ? 'first' : null);
172
+ }
173
+
174
+ /** Close every open menu. Focus stays in the bar if it was there. */
175
+ close() { this._closeAll(); }
176
+
177
+ /** Focus the bar's first title, as Alt or F10 does. */
178
+ focus() {
179
+ this._enter();
180
+ this._focusTitle(0);
181
+ }
182
+
183
+ isEnabled(id) {
184
+ const item = this._findModel(id);
185
+ return item ? !this._isDisabled(item) : !this._disabled.get(id);
186
+ }
187
+
188
+ isChecked(id) {
189
+ const item = this._findModel(id);
190
+ return item ? this._isChecked(item) : !!this._checked.get(id);
191
+ }
192
+
193
+ /** Enable or disable every item with this id; an open menu is patched in place. */
194
+ setEnabled(id, enabled) {
195
+ this._disabled.set(id, !enabled);
196
+ this._patch(id);
197
+ }
198
+
199
+ /** Check or uncheck every item with this id; checking a radio item
200
+ * unchecks the rest of its group. An open menu is patched in place. */
201
+ setChecked(id, checked) {
202
+ this._check(this._findModel(id), id, checked);
203
+ }
204
+
205
+ /**
206
+ * The model as command-palette rows: one per enabled leaf item, labelled
207
+ * as in the menu, with its `shortcut`, and its menu path as `context`
208
+ * ('File › Open recent'). A function submenu is called now, so the rows
209
+ * are as current as the menu would be. `action` runs the item exactly as
210
+ * a click would, including flipping a check or radio item.
211
+ */
212
+ commands() {
213
+ const rows = [];
214
+ const walk = (items, path) => {
215
+ for (const it of _resolve(items)) {
216
+ if (!it || it.separator || this._isDisabled(it)) continue;
217
+ if (it.submenu != null) { walk(it.submenu, [...path, it.label]); continue; }
218
+ rows.push({
219
+ id: `menubar:${it.id}`,
220
+ label: it.label,
221
+ ...(it.shortcut ? { shortcut: it.shortcut } : {}),
222
+ context: path.join(' › '),
223
+ verb: 'run',
224
+ action: () => this._fire(it),
225
+ });
226
+ }
227
+ };
228
+ for (const m of this.menus) walk(m.items, [m.label]);
229
+ return rows;
230
+ }
231
+
232
+ dispose() {
233
+ this._closeAll();
234
+ for (const k of ['mousedown', 'click', 'mouseover', 'keydown', 'keyup', 'focusin', 'focusout']) {
235
+ this.el.removeEventListener(k, this._h[k]);
236
+ }
237
+ this._doc.removeEventListener('keydown', this._h.docKeydown, true);
238
+ this._doc.removeEventListener('keyup', this._h.docKeyup, true);
239
+ this._doc.removeEventListener('mousedown', this._h.docMousedown, true);
240
+ this._doc.defaultView?.removeEventListener('blur', this._h.winBlur);
241
+ this.el.innerHTML = '';
242
+ this.el.classList.remove('twm-menubar');
243
+ this.el.removeAttribute('role');
244
+ this.el.removeAttribute('aria-label');
245
+ }
246
+
247
+ // ── state ─────────────────────────────────────────────────────────
248
+
249
+ _isDisabled(item) {
250
+ return this._disabled.has(item.id) ? this._disabled.get(item.id) : !!item.disabled;
251
+ }
252
+
253
+ _isChecked(item) {
254
+ if (item.radio != null && this._radio.has(item.radio)) return this._radio.get(item.radio) === item.id;
255
+ return this._checked.has(item.id) ? this._checked.get(item.id) : !!item.checked;
256
+ }
257
+
258
+ /** Record a check. `item` is the model item when the bar knows it (a
259
+ * radio item then records its group's selection); `id` alone is kept as
260
+ * an override for an item the model does not hold yet. */
261
+ _check(item, id, checked) {
262
+ if (item?.radio != null) {
263
+ const group = item.radio;
264
+ if (checked) this._radio.set(group, id);
265
+ else if (this._isChecked(item)) this._radio.set(group, null);
266
+ this._patchGroup(group);
267
+ return;
268
+ }
269
+ this._checked.set(id, !!checked);
270
+ this._patch(id);
271
+ }
272
+
273
+ /** Every model item the bar knows of: the model, with every function
274
+ * submenu called now (as `commands()` does), and whatever is on screen. */
275
+ _knownItems() {
276
+ const out = [];
277
+ const walk = (items) => {
278
+ for (const it of _resolve(items)) {
279
+ if (!it || it.separator) continue;
280
+ out.push(it);
281
+ if (it.submenu != null) walk(it.submenu);
282
+ }
283
+ };
284
+ for (const m of this.menus) walk(m.items);
285
+ for (const b of this.el.querySelectorAll(SEL_ITEM)) {
286
+ const it = this._models.get(b);
287
+ if (it && !out.includes(it)) out.push(it);
288
+ }
289
+ return out;
290
+ }
291
+
292
+ _findModel(id) { return this._knownItems().find((it) => it.id === id) || null; }
293
+
294
+ /** Re-apply the state of every rendered item with this id. */
295
+ _patch(id) {
296
+ for (const b of this.el.querySelectorAll(SEL_ITEM)) {
297
+ const it = this._models.get(b);
298
+ if (it && it.id === id) this._apply(b, it);
299
+ }
300
+ }
301
+
302
+ /** Re-apply the state of every rendered item of this radio group. */
303
+ _patchGroup(group) {
304
+ for (const b of this.el.querySelectorAll(SEL_ITEM)) {
305
+ const it = this._models.get(b);
306
+ if (it && it.radio === group) this._apply(b, it);
307
+ }
308
+ }
309
+
310
+ _apply(btn, item) {
311
+ const off = this._isDisabled(item);
312
+ btn.classList.toggle('twm-menubar__item--disabled', off);
313
+ if (off) btn.setAttribute('aria-disabled', 'true');
314
+ else btn.removeAttribute('aria-disabled');
315
+ if (_kind(item) !== 'menuitem') {
316
+ btn.setAttribute('aria-checked', this._isChecked(item) ? 'true' : 'false');
317
+ }
318
+ }
319
+
320
+ // ── open / close ──────────────────────────────────────────────────
321
+
322
+ _setRoving(i) {
323
+ this._titles.forEach((t, j) => { t.tabIndex = j === i ? 0 : -1; });
324
+ }
325
+
326
+ _focusTitle(i) {
327
+ this._setRoving(i);
328
+ this._titles[i]?.focus({ preventScroll: true });
329
+ }
330
+
331
+ /** The bar becomes active; remember what to give focus back to. */
332
+ _enter(from = this._doc.activeElement) {
333
+ if (this._active) return;
334
+ this._active = true;
335
+ this._returnTo = from && from !== this._doc.body && !this.el.contains(from) ? from : null;
336
+ }
337
+
338
+ /** Leave the bar and give focus back. */
339
+ _exit() {
340
+ const back = this._returnTo;
341
+ this._closeAll();
342
+ this._active = false;
343
+ this._returnTo = null;
344
+ if (back?.isConnected) back.focus({ preventScroll: true });
345
+ else if (this.el.contains(this._doc.activeElement)) this._doc.activeElement.blur();
346
+ }
347
+
348
+ /** Focus went elsewhere on purpose: close, give nothing back. */
349
+ _deactivate() {
350
+ this._closeAll();
351
+ this._active = false;
352
+ this._returnTo = null;
353
+ }
354
+
355
+ _open(i, focus) {
356
+ const n = this._titles.length;
357
+ i = ((i % n) + n) % n;
358
+ // Focus moves to the title BEFORE the old menu goes, so the element
359
+ // being removed never holds focus.
360
+ this._focusTitle(i);
361
+ this._closeAll();
362
+ const m = this.menus[i];
363
+ const title = this._titles[i];
364
+ const menu = this._buildMenu(m.items, m.label);
365
+ title.parentElement.appendChild(menu);
366
+ title.setAttribute('aria-expanded', 'true');
367
+ this._stack = [{ menu, parent: title }];
368
+ this._openIdx = i;
369
+ _place(menu, title, 'below');
370
+ this._focusIn(menu, focus);
371
+ }
372
+
373
+ _openSub(btn, focus) {
374
+ const item = this._models.get(btn);
375
+ if (!item || item.submenu == null || this._isDisabled(item)) return;
376
+ const level = this._levelOf(btn);
377
+ if (level < 0) return;
378
+ const open = this._stack[level + 1];
379
+ if (open && open.parent === btn) { this._focusIn(open.menu, focus); return; }
380
+ this._closeFrom(level + 1);
381
+ const menu = this._buildMenu(item.submenu, item.label); // a function is called here, every time
382
+ btn.parentElement.appendChild(menu);
383
+ btn.setAttribute('aria-expanded', 'true');
384
+ this._stack.push({ menu, parent: btn });
385
+ _place(menu, btn, 'right');
386
+ this._focusIn(menu, focus);
387
+ }
388
+
389
+ _focusIn(menu, focus) {
390
+ if (!focus) return;
391
+ const items = this._enabledIn(menu);
392
+ (focus === 'last' ? items[items.length - 1] : items[0])?.focus({ preventScroll: true });
393
+ }
394
+
395
+ /** Close the menus at `level` and deeper. Focus inside them goes to the
396
+ * item (or title) they hang from. */
397
+ _closeFrom(level) {
398
+ if (level >= this._stack.length) return;
399
+ const first = this._stack[level];
400
+ if (this._stack.slice(level).some((s) => s.menu.contains(this._doc.activeElement))) {
401
+ first.parent.focus({ preventScroll: true });
402
+ }
403
+ for (const s of this._stack.splice(level)) {
404
+ s.menu.remove();
405
+ s.parent.setAttribute('aria-expanded', 'false');
406
+ }
407
+ if (level === 0) this._openIdx = -1;
408
+ }
409
+
410
+ _closeAll() { this._closeFrom(0); }
411
+
412
+ _levelOf(el) {
413
+ const menu = el.closest(SEL_MENU);
414
+ return this._stack.findIndex((s) => s.menu === menu);
415
+ }
416
+
417
+ /** The items the keyboard can land on: no separators, nothing disabled. */
418
+ _enabledIn(menu) {
419
+ return this._itemsIn(menu).filter((b) => !this._isDisabled(this._models.get(b)));
420
+ }
421
+
422
+ /** Every item cell of `menu` in DOM order, disabled ones included. */
423
+ _itemsIn(menu) {
424
+ return [...menu.children]
425
+ .map((w) => w.firstElementChild)
426
+ .filter((b) => b?.matches?.(SEL_ITEM));
427
+ }
428
+
429
+ /** The next (`dir` 1) or previous (-1) enabled item from `btn`'s place
430
+ * among all the cells, wrapping. `btn` itself may be disabled: one
431
+ * setEnabled(id, false) leaves focus on it. */
432
+ _stepFrom(menu, btn, dir) {
433
+ const all = this._itemsIn(menu);
434
+ const n = all.length;
435
+ const at = all.indexOf(btn);
436
+ for (let k = 1; k <= n; k++) {
437
+ const b = all[(((at + dir * k) % n) + n) % n];
438
+ if (!this._isDisabled(this._models.get(b))) return b;
439
+ }
440
+ return null;
441
+ }
442
+
443
+ _buildMenu(items, label) {
444
+ const d = this._doc;
445
+ const menu = d.createElement('div');
446
+ menu.className = 'twm-menubar__menu';
447
+ menu.setAttribute('role', 'menu');
448
+ menu.setAttribute('aria-label', label);
449
+ const list = _resolve(items).filter((it) => {
450
+ if (it && (it.separator || (it.id != null && it.label != null))) return true;
451
+ console.warn('[menubar] an item needs an id and a label', it);
452
+ return false;
453
+ });
454
+ const shown = list.length ? list : [{ id: null, label: this._emptyLabel, disabled: true, _empty: true }];
455
+ for (const it of shown) {
456
+ if (it.separator) {
457
+ const sep = d.createElement('div');
458
+ sep.className = 'twm-menubar__separator';
459
+ sep.setAttribute('role', 'separator');
460
+ menu.appendChild(sep);
461
+ continue;
462
+ }
463
+ const wrap = d.createElement('div');
464
+ wrap.className = 'twm-menubar__cell';
465
+ wrap.setAttribute('role', 'none');
466
+ const btn = d.createElement('button');
467
+ btn.type = 'button';
468
+ btn.tabIndex = -1;
469
+ const kind = _kind(it);
470
+ btn.className = 'twm-menubar__item';
471
+ if (kind !== 'menuitem') btn.classList.add(kind === 'menuitemradio' ? 'twm-menubar__item--radio' : 'twm-menubar__item--check');
472
+ btn.setAttribute('role', kind);
473
+ if (it.id != null) btn.dataset.itemId = it.id;
474
+ const check = d.createElement('span');
475
+ check.className = 'twm-menubar__check';
476
+ check.setAttribute('aria-hidden', 'true');
477
+ const text = d.createElement('span');
478
+ text.className = 'twm-menubar__label';
479
+ text.textContent = it.label;
480
+ btn.append(check, text);
481
+ if (it.shortcut) {
482
+ const sc = d.createElement('span');
483
+ sc.className = 'twm-menubar__shortcut';
484
+ sc.textContent = it.shortcut;
485
+ btn.appendChild(sc);
486
+ }
487
+ if (it.submenu != null) {
488
+ btn.setAttribute('aria-haspopup', 'menu');
489
+ btn.setAttribute('aria-expanded', 'false');
490
+ btn.classList.add('twm-menubar__item--submenu');
491
+ const arrow = d.createElement('span');
492
+ arrow.className = 'twm-menubar__arrow';
493
+ arrow.setAttribute('aria-hidden', 'true');
494
+ btn.appendChild(arrow);
495
+ }
496
+ this._models.set(btn, it);
497
+ this._apply(btn, it);
498
+ wrap.appendChild(btn);
499
+ menu.appendChild(wrap);
500
+ }
501
+ return menu;
502
+ }
503
+
504
+ // ── activation ────────────────────────────────────────────────────
505
+
506
+ _activate(btn) {
507
+ const item = this._models.get(btn);
508
+ if (!item || this._isDisabled(item)) return;
509
+ if (item.submenu != null) { this._openSub(btn, 'first'); return; }
510
+ this._exit();
511
+ this._fire(item);
512
+ }
513
+
514
+ /** Flip a check/radio item and tell the embedder. The ONE door to
515
+ * `onAction`, for clicks, keys and palette rows alike. */
516
+ _fire(item) {
517
+ if (!item || item.separator || item._empty || this._isDisabled(item) || item.submenu != null) return;
518
+ let checked;
519
+ // The item itself, not its id: it carries its own radio group even
520
+ // when it came from a function submenu that is not on screen.
521
+ if (_kind(item) === 'menuitemradio') { this._check(item, item.id, true); checked = true; }
522
+ else if (_kind(item) === 'menuitemcheckbox') { checked = !this._isChecked(item); this._check(item, item.id, checked); }
523
+ this._onAction?.(item.id, { item, checked });
524
+ }
525
+
526
+ // ── events ────────────────────────────────────────────────────────
527
+
528
+ _onMouseDown(e) {
529
+ // Before the button could take focus: remember what had it. No press
530
+ // in the bar moves focus by itself — not on a title or an item, nor
531
+ // on a separator, a menu's padding or the bar's empty space — the bar
532
+ // moves it (a title on click, an item on hover), so focus never drops
533
+ // to nothing on the way, which would end the bar's activation (see
534
+ // _onFocusOut).
535
+ if (e.target.closest?.(SEL_TITLE)) this._enter();
536
+ e.preventDefault();
537
+ }
538
+
539
+ _onClick(e) {
540
+ const title = e.target.closest?.(SEL_TITLE);
541
+ if (title) {
542
+ const i = this._titles.indexOf(title);
543
+ this._enter();
544
+ if (this._openIdx === i) this._closeAll();
545
+ else this._open(i, null);
546
+ return;
547
+ }
548
+ const btn = e.target.closest?.(SEL_ITEM);
549
+ if (btn) { e.stopPropagation(); this._activate(btn); }
550
+ }
551
+
552
+ _onMouseOver(e) {
553
+ const title = e.target.closest?.(SEL_TITLE);
554
+ if (title) {
555
+ const i = this._titles.indexOf(title);
556
+ if (this._openIdx >= 0 && this._openIdx !== i) this._open(i, null);
557
+ return;
558
+ }
559
+ const btn = e.target.closest?.(SEL_ITEM);
560
+ if (!btn) return;
561
+ const item = this._models.get(btn);
562
+ if (!item || this._isDisabled(item)) return;
563
+ btn.focus({ preventScroll: true });
564
+ const level = this._levelOf(btn);
565
+ if (item.submenu != null) this._openSub(btn, null);
566
+ else if (level >= 0) this._closeFrom(level + 1);
567
+ }
568
+
569
+ /** Tabbed into: what had focus is the event's `relatedTarget`. */
570
+ _onFocusIn(e) {
571
+ if (!this._active) this._enter(e.relatedTarget);
572
+ }
573
+
574
+ /** Focus left the bar: to an element outside it, or to nothing (a press
575
+ * on a non-focusable area, the window losing focus). Either way the
576
+ * activation ends, so the next Alt/F10 remembers what has focus THEN. */
577
+ _onFocusOut(e) {
578
+ const to = e.relatedTarget;
579
+ if (!to || !this.el.contains(to)) this._deactivate();
580
+ }
581
+
582
+ _onKey(e) {
583
+ if (e.ctrlKey || e.metaKey || e.altKey) return; // app chords pass through
584
+ const title = e.target.closest?.(SEL_TITLE);
585
+ const btn = title ? null : e.target.closest?.(SEL_ITEM);
586
+ if (!title && !btn) return;
587
+ if (e.key === 'Tab') { this._deactivate(); return; } // focus moves on, as Tab says
588
+ const handled = title ? this._titleKey(e.key, this._titles.indexOf(title))
589
+ : this._itemKey(e.key, btn);
590
+ if (handled) e.preventDefault();
591
+ // Keys meant for the bar do not reach the page's own bindings
592
+ // (Backspace is a "go back" in the window manager). F-keys do.
593
+ if (handled || !/^F\d+$/.test(e.key)) e.stopPropagation();
594
+ }
595
+
596
+ _titleKey(key, i) {
597
+ const n = this._titles.length;
598
+ const open = this._openIdx >= 0;
599
+ switch (key) {
600
+ case 'ArrowRight': open ? this._open(i + 1, null) : this._focusTitle((i + 1) % n); return true;
601
+ case 'ArrowLeft': open ? this._open(i - 1, null) : this._focusTitle((i - 1 + n) % n); return true;
602
+ case 'Home': open ? this._open(0, null) : this._focusTitle(0); return true;
603
+ case 'End': open ? this._open(n - 1, null) : this._focusTitle(n - 1); return true;
604
+ case 'ArrowDown': case 'Enter': case ' ':
605
+ if (open && this._openIdx === i) this._focusIn(this._stack[0].menu, 'first');
606
+ else this._open(i, 'first');
607
+ return true;
608
+ case 'ArrowUp':
609
+ if (open && this._openIdx === i) this._focusIn(this._stack[0].menu, 'last');
610
+ else this._open(i, 'last');
611
+ return true;
612
+ case 'Escape':
613
+ if (open) this._closeAll();
614
+ else this._exit();
615
+ return true;
616
+ default: return false;
617
+ }
618
+ }
619
+
620
+ _itemKey(key, btn) {
621
+ const level = this._levelOf(btn);
622
+ if (level < 0) return false;
623
+ const menu = this._stack[level].menu;
624
+ const item = this._models.get(btn);
625
+ const go = (b) => b?.focus({ preventScroll: true });
626
+ // A submenu can be open while focus is still on the item it hangs
627
+ // from (opened by the pointer, or with no row the keyboard can land
628
+ // on). Left and Esc close that submenu first, keeping focus here.
629
+ if ((key === 'ArrowLeft' || key === 'Escape') && this._stack[level + 1]?.parent === btn) {
630
+ this._closeFrom(level + 1);
631
+ return true;
632
+ }
633
+ // Moving within a menu closes any submenu open below it, as hovering
634
+ // another item does, so no stale submenu stays next to the focus.
635
+ if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Home' || key === 'End') {
636
+ this._closeFrom(level + 1);
637
+ }
638
+ switch (key) {
639
+ case 'ArrowDown': go(this._stepFrom(menu, btn, 1)); return true;
640
+ case 'ArrowUp': go(this._stepFrom(menu, btn, -1)); return true;
641
+ case 'Home': this._focusIn(menu, 'first'); return true;
642
+ case 'End': this._focusIn(menu, 'last'); return true;
643
+ case 'ArrowRight':
644
+ if (item?.submenu != null && !this._isDisabled(item)) this._openSub(btn, 'first');
645
+ else this._open(this._openIdx + 1, 'first');
646
+ return true;
647
+ case 'ArrowLeft':
648
+ if (level > 0) this._closeFrom(level);
649
+ else this._open(this._openIdx - 1, 'first');
650
+ return true;
651
+ case 'Enter': case ' ':
652
+ this._activate(btn);
653
+ return true;
654
+ case 'Escape':
655
+ if (level > 0) this._closeFrom(level);
656
+ else this._closeAll();
657
+ return true;
658
+ default: return false;
659
+ }
660
+ }
661
+
662
+ /** Alt on its own, and F10: armed on keydown, acted on here or on keyup. */
663
+ _onDocKeyDown(e) {
664
+ if (e.key === 'Alt') {
665
+ this._altArmed = !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.repeat;
666
+ return;
667
+ }
668
+ this._altArmed = false;
669
+ if (e.key === 'F10' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
670
+ e.preventDefault();
671
+ e.stopPropagation(); // F10 is the bar's while one is mounted
672
+ this._toggle();
673
+ }
674
+ }
675
+
676
+ _onDocKeyUp(e) {
677
+ if (e.key !== 'Alt') return;
678
+ const armed = this._altArmed;
679
+ this._altArmed = false;
680
+ if (!armed) return;
681
+ e.preventDefault();
682
+ this._toggle();
683
+ }
684
+
685
+ _onDocMouseDown(e) {
686
+ this._altArmed = false;
687
+ if (this._openIdx >= 0 && !this.el.contains(e.target)) this._deactivate();
688
+ }
689
+
690
+ _toggle() {
691
+ if (this._active && (this._openIdx >= 0 || this.el.contains(this._doc.activeElement))) this._exit();
692
+ else this.focus();
693
+ }
694
+ }
695
+
696
+ // ── helpers ──────────────────────────────────────────────────────────
697
+
698
+ function _resolve(items) {
699
+ const v = typeof items === 'function' ? items() : items;
700
+ return Array.isArray(v) ? v : [];
701
+ }
702
+
703
+ function _kind(item) {
704
+ if (item.radio != null) return 'menuitemradio';
705
+ if (typeof item.checked === 'boolean') return 'menuitemcheckbox';
706
+ return 'menuitem';
707
+ }
708
+
709
+ /** A bad model is a boot error, not a menu that silently lacks a row. */
710
+ function _validateMenus(menus) {
711
+ if (!Array.isArray(menus) || !menus.length) {
712
+ throw new TypeError('createMenuBar: `menus` must be a non-empty array');
713
+ }
714
+ const walk = (items, where) => {
715
+ if (typeof items === 'function') return;
716
+ if (!Array.isArray(items)) throw new TypeError(`createMenuBar: ${where} needs \`items\` (an array or a function)`);
717
+ for (const it of items) {
718
+ if (it?.separator) continue;
719
+ if (it?.id == null || it?.label == null) {
720
+ throw new TypeError(`createMenuBar: an item in ${where} needs an id and a label`);
721
+ }
722
+ if (it.submenu != null) walk(it.submenu, `"${it.label}"`);
723
+ }
724
+ };
725
+ for (const m of menus) {
726
+ if (m?.id == null || m?.label == null) throw new TypeError('createMenuBar: a menu needs an id and a label');
727
+ walk(m.items, `menu "${m.label}"`);
728
+ }
729
+ }
730
+
731
+ /** Fixed, so a bar inside an `overflow: hidden` chrome strip is not clipped;
732
+ * kept on screen. `below` drops from a title, `right` flies out of an item. */
733
+ function _place(menu, anchor, side) {
734
+ const view = menu.ownerDocument.defaultView;
735
+ const a = anchor.getBoundingClientRect();
736
+ const r = menu.getBoundingClientRect();
737
+ const vw = view?.innerWidth || 0;
738
+ const vh = view?.innerHeight || 0;
739
+ let left = side === 'below' ? a.left : a.right;
740
+ let top = side === 'below' ? a.bottom : a.top;
741
+ if (vw && left + r.width > vw) left = side === 'below' ? vw - r.width - 4 : a.left - r.width;
742
+ if (vh && top + r.height > vh) top = Math.max(0, vh - r.height - 4);
743
+ menu.style.left = `${Math.max(0, left)}px`;
744
+ menu.style.top = `${Math.max(0, top)}px`;
745
+ }