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.
- package/CHANGELOG.md +93 -0
- package/LICENSE +0 -0
- package/README.md +2261 -42
- package/THIRD_PARTY_LICENSES.md +0 -0
- package/css/base.css +1834 -75
- package/css/flexdesk.css +3373 -238
- package/css/overrides.css +16 -0
- package/css/reset.css +0 -0
- package/css/tokens.css +0 -0
- package/dist/canvas.js +6 -239
- package/dist/canvas.js.map +3 -3
- package/dist/charts.js +709 -18
- package/dist/charts.js.map +4 -4
- package/dist/chunk-3PHCPZHT.js +0 -0
- package/dist/chunk-3PHCPZHT.js.map +0 -0
- package/dist/chunk-4A7SJC3T.js +503 -0
- package/dist/chunk-4A7SJC3T.js.map +7 -0
- package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
- package/dist/chunk-CVMCZZX2.js.map +7 -0
- package/dist/chunk-D3COLZZ2.js +1311 -0
- package/dist/chunk-D3COLZZ2.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +0 -0
- package/dist/chunk-FOOS3T5L.js.map +0 -0
- package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
- package/dist/chunk-GOQLR5WR.js.map +7 -0
- package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
- package/dist/chunk-HNU6MM2B.js.map +7 -0
- package/dist/chunk-HYJYYEPK.js +375 -0
- package/dist/chunk-HYJYYEPK.js.map +7 -0
- package/dist/chunk-JKZLNKG6.js +2041 -0
- package/dist/chunk-JKZLNKG6.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +0 -0
- package/dist/chunk-JYWURG5T.js.map +0 -0
- package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
- package/dist/chunk-MCIXFNVF.js.map +7 -0
- package/dist/chunk-N7HC3ADV.js +30 -0
- package/dist/chunk-N7HC3ADV.js.map +7 -0
- package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
- package/dist/chunk-Q36EPT2R.js.map +7 -0
- package/dist/chunk-QWQJUDPD.js +610 -0
- package/dist/chunk-QWQJUDPD.js.map +7 -0
- package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
- package/dist/chunk-UURZ37SD.js.map +7 -0
- package/dist/chunk-UWO43BZU.js +111 -0
- package/dist/chunk-UWO43BZU.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +0 -0
- package/dist/chunk-WVFGV5FT.js.map +0 -0
- package/dist/core.js +170 -118
- package/dist/core.js.map +2 -2
- package/dist/editor.js +156 -70
- package/dist/editor.js.map +2 -2
- package/dist/flexdesk.css +3373 -238
- package/dist/flow.js +2789 -306
- package/dist/flow.js.map +4 -4
- package/dist/host.js +109 -3
- package/dist/host.js.map +3 -3
- package/dist/reset.css +0 -0
- package/dist/tile_registry-6WZPTUZV.js +0 -0
- package/dist/tile_registry-6WZPTUZV.js.map +0 -0
- package/dist/tiles.js +1131 -253
- package/dist/tiles.js.map +4 -4
- package/dist/tokens.css +0 -0
- package/dist/widgets.js +3254 -2112
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +1227 -345
- package/dist/wm.js.map +4 -4
- package/package.json +3 -1
- package/src/canvas/canvas_adapter.js +5 -0
- package/src/canvas/edge_router.js +0 -0
- package/src/canvas/graph/connector_router.js +0 -0
- package/src/canvas/graph/history_service.js +0 -0
- package/src/canvas/graph/node_platform.js +0 -0
- package/src/canvas/index.js +1 -0
- package/src/canvas/layered_layout.js +244 -0
- package/src/charts/chart_types.js +0 -0
- package/src/charts/data_series_plot_window.js +0 -0
- package/src/charts/downsample.js +2 -2
- package/src/charts/plot_config.js +53 -29
- package/src/charts/plot_config_editor.js +627 -0
- package/src/charts/plot_grid.js +449 -0
- package/src/charts/plot_popout_window.js +103 -317
- package/src/charts/plotly_wrapper.js +22 -2
- package/src/charts/series_transforms.js +206 -0
- package/src/core/event_bus.js +0 -0
- package/src/core/logging.js +1 -1
- package/src/core/settings.js +105 -19
- package/src/core/state_guard.js +93 -51
- package/src/core/state_guard_config.js +85 -74
- package/src/core/state_machine.js +0 -0
- package/src/editor/editor_pane.js +3 -3
- package/src/editor/monaco_editor_factory.js +124 -60
- package/src/editor/monaco_loader.js +103 -28
- package/src/editor/notebook_search.js +0 -0
- package/src/editor/notebook_tab_bar.js +38 -52
- package/src/editor/notebook_undo_manager.js +0 -0
- package/src/editor/split_pane_container.js +6 -3
- package/src/flow/canvas_editor/connections.js +90 -0
- package/src/flow/canvas_editor/editor.js +2351 -153
- package/src/flow/canvas_editor/index.js +2 -1
- package/src/flow/canvas_editor/layout.js +119 -12
- package/src/flow/canvas_editor/strings.js +80 -0
- package/src/flow/kit/always_before.js +0 -0
- package/src/flow/kit/catalogue.js +97 -3
- package/src/flow/kit/chip_input.js +0 -0
- package/src/flow/kit/dom.js +0 -0
- package/src/flow/kit/findings.js +0 -0
- package/src/flow/kit/history.js +0 -0
- package/src/flow/kit/index.js +3 -3
- package/src/flow/kit/keys.js +7 -1
- package/src/flow/kit/logic_graph.js +97 -15
- package/src/flow/kit/references.js +0 -0
- package/src/flow/kit/settings_panel.js +0 -0
- package/src/flow/kit/settings_schema.js +2 -1
- package/src/flow/kit/step_picker.js +0 -0
- package/src/flow/kit/strings.js +3 -1
- package/src/flow/kit/value_picker.js +0 -0
- package/src/flow/kit/widgets.js +86 -109
- package/src/flow/lanes/editor.js +136 -14
- package/src/flow/lanes/index.js +3 -1
- package/src/flow/lanes/layout.js +0 -0
- package/src/flow/lanes/pipeline.js +44 -2
- package/src/flow/lanes/preview.js +0 -0
- package/src/flow/lanes/strings.js +1 -0
- package/src/flow/outline/editor.js +0 -0
- package/src/flow/outline/index.js +0 -0
- package/src/flow/outline/mapping.js +0 -0
- package/src/flow/outline/menu.js +0 -0
- package/src/flow/outline/operations.js +0 -0
- package/src/flow/outline/recognise.js +0 -0
- package/src/flow/outline/strings.js +0 -0
- package/src/help/help_modal.js +168 -42
- package/src/help/help_registry.js +12 -1
- package/src/host/external_links.js +0 -0
- package/src/host/file_picker.js +117 -0
- package/src/host/host.js +10 -2
- package/src/host/photino_host.js +9 -0
- package/src/host/photino_host.md +21 -3
- package/src/host/pywebview_host.js +10 -0
- package/src/tiles/chart_tiles.js +53 -245
- package/src/tiles/config_schema.js +8 -0
- package/src/tiles/figure_tile.js +322 -0
- package/src/tiles/layout_persistence.js +3 -3
- package/src/tiles/list_tiles.js +422 -0
- package/src/tiles/scope_picker.js +96 -0
- package/src/tiles/tile_base.js +12 -1
- package/src/tiles/tile_config_form.js +442 -0
- package/src/tiles/tile_grid.js +38 -1
- package/src/tiles/tile_registry.js +0 -0
- package/src/tiling/bottom_panel_tabs.js +67 -99
- package/src/tiling/command_palette.js +64 -19
- package/src/tiling/content_registry.js +4 -1
- package/src/tiling/desktops.js +11 -1
- package/src/tiling/entity_sources.js +0 -0
- package/src/tiling/keymap.js +403 -128
- package/src/tiling/kind_taxonomy.js +78 -0
- package/src/tiling/landing_table.js +0 -0
- package/src/tiling/loading_overlay.js +0 -0
- package/src/tiling/nav_panel.js +59 -10
- package/src/tiling/page_factory.js +5 -5
- package/src/tiling/panel_availability.js +49 -0
- package/src/tiling/panel_keys.js +0 -0
- package/src/tiling/shell.js +114 -12
- package/src/tiling/tab_strip.js +0 -0
- package/src/tiling/tile_breadcrumb.js +0 -0
- package/src/tiling/tile_renderer.js +105 -9
- package/src/tiling/tile_tab_menu.js +0 -0
- package/src/tiling/tile_tree.js +0 -0
- package/src/tiling/wm.js +562 -30
- package/src/tiling/zoom.js +0 -0
- package/src/ui/base/component_base.js +0 -0
- package/src/ui/base/controller_base.js +0 -0
- package/src/ui/base/page_base.js +0 -0
- package/src/ui/components/about_dialog.js +0 -0
- package/src/ui/components/action_dropdown.js +30 -2
- package/src/ui/components/alert_log.js +0 -0
- package/src/ui/components/alerts_pane.js +0 -0
- package/src/ui/components/attribute_list_editor.js +0 -0
- package/src/ui/components/autocomplete_field.js +0 -0
- package/src/ui/components/cell_editor_host.js +211 -0
- package/src/ui/components/cell_grid.js +648 -0
- package/src/ui/components/cell_selection.js +220 -0
- package/src/ui/components/choice_controls.js +351 -0
- package/src/ui/components/color_field.js +211 -0
- package/src/ui/components/color_list_field.js +210 -0
- package/src/ui/components/column_fit.js +0 -0
- package/src/ui/components/column_groups.js +314 -0
- package/src/ui/components/column_order.js +142 -0
- package/src/ui/components/computing_status_window.js +98 -25
- package/src/ui/components/confirm_dialog.js +81 -38
- package/src/ui/components/context_menu.js +2 -2
- package/src/ui/components/conversation_view.js +440 -0
- package/src/ui/components/crosstab.js +289 -0
- package/src/ui/components/data_table.js +601 -62
- package/src/ui/components/detail_header.js +22 -11
- package/src/ui/components/document_outline.js +397 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/fact_table.js +0 -0
- package/src/ui/components/fill_handle.js +322 -0
- package/src/ui/components/gallery_picker.js +19 -8
- package/src/ui/components/header_actions.js +55 -0
- package/src/ui/components/inline_renamer.js +0 -0
- package/src/ui/components/job_progress.js +51 -0
- package/src/ui/components/managed_window.js +27 -4
- package/src/ui/components/menu_bar.js +745 -0
- package/src/ui/components/modal.js +449 -75
- package/src/ui/components/notification_history.js +3 -3
- package/src/ui/components/progress.js +483 -0
- package/src/ui/components/record_list.js +0 -0
- package/src/ui/components/row_activation.js +0 -0
- package/src/ui/components/section_stack.js +298 -0
- package/src/ui/components/series.js +173 -0
- package/src/ui/components/settings_view.js +634 -0
- package/src/ui/components/slide_out_panel.js +43 -6
- package/src/ui/components/slider_field.js +275 -18
- package/src/ui/components/sortable_list.js +26 -4
- package/src/ui/components/staged_edits.js +343 -0
- package/src/ui/components/staged_history.js +172 -0
- package/src/ui/components/table_filter_chips.js +0 -0
- package/src/ui/components/table_state_store.js +0 -0
- package/src/ui/components/table_structure.js +182 -0
- package/src/ui/components/table_values.js +0 -0
- package/src/ui/components/tabs.js +497 -0
- package/src/ui/components/tag_input.js +215 -0
- package/src/ui/components/toast.js +25 -8
- package/src/ui/components/toggle_field.js +175 -0
- package/src/ui/components/tree_view.js +55 -34
- package/src/ui/components/window_taskbar.js +0 -0
- package/src/ui/components/wizard.js +563 -0
- package/src/ui/controllers/window_chrome_controller.js +2 -2
- package/src/ui/notification_center.js +375 -119
- package/src/ui/utils/auto_scrollbars.js +0 -0
- package/src/ui/utils/markdown.js +709 -0
- package/src/ui/utils/native_context_menu.js +0 -0
- package/src/ui/utils/overlay_scrollbar.js +16 -12
- package/src/ui/utils/raf_resize_observer.js +0 -0
- package/src/ui/utils/scroll_lock.js +0 -0
- package/src/ui/utils/strip_scroll.js +55 -0
- package/src/ui/utils/tooltip_service.js +0 -8
- package/dist/chunk-6JSOVNID.js.map +0 -7
- package/dist/chunk-CWPMLX67.js +0 -475
- package/dist/chunk-CWPMLX67.js.map +0 -7
- package/dist/chunk-FBGP5KN5.js.map +0 -7
- package/dist/chunk-FL5KFNQH.js.map +0 -7
- package/dist/chunk-NLTOY57M.js.map +0 -7
- package/dist/chunk-NNFTETK3.js +0 -633
- package/dist/chunk-NNFTETK3.js.map +0 -7
- package/dist/chunk-O5OHMWBB.js +0 -284
- package/dist/chunk-O5OHMWBB.js.map +0 -7
- package/dist/chunk-RQM5Z2MU.js.map +0 -7
- package/dist/chunk-SN4LSOAR.js.map +0 -7
- 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
|
+
}
|