@gjsify/adwaita-web 0.64.13 → 0.64.15-next.287
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/lib/types/application.d.ts +3 -3
- package/lib/types/elements/adw-application-window.d.ts +4 -4
- package/lib/types/elements/adw-split-button.d.ts +19 -28
- package/lib/types/elements/adw-tab-overview.d.ts +4 -4
- package/lib/types/elements/gtk-menu-button.d.ts +30 -42
- package/lib/types/elements/gtk-popover-bin.d.ts +18 -11
- package/lib/types/elements/gtk-popover-menu-bar.d.ts +15 -8
- package/lib/types/elements/gtk-popover-menu.d.ts +13 -15
- package/lib/types/elements/menu-slot.d.ts +49 -0
- package/lib/types/elements/popover-menu.d.ts +11 -7
- package/lib/types/index.d.ts +2 -1
- package/lib/types/namespace/gio.d.ts +1 -1
- package/package.json +13 -12
- package/src/adw-application-window.spec.ts +5 -2
- package/src/application.ts +4 -4
- package/src/elements/adw-application-window.ts +17 -21
- package/src/elements/adw-split-button.ts +35 -59
- package/src/elements/adw-tab-overview.ts +18 -28
- package/src/elements/gtk-menu-button.ts +60 -82
- package/src/elements/gtk-popover-bin.ts +32 -38
- package/src/elements/gtk-popover-menu-bar.ts +41 -46
- package/src/elements/gtk-popover-menu.ts +35 -54
- package/src/elements/menu-slot.ts +139 -0
- package/src/elements/popover-menu.ts +26 -16
- package/src/gio-menu.spec.ts +173 -34
- package/src/gtk-image.spec.ts +3 -2
- package/src/gtk-menu-button.spec.ts +19 -16
- package/src/gtk-popover-bin.spec.ts +6 -5
- package/src/gtk-popover-menu-bar.spec.ts +11 -6
- package/src/gtk-popover-menu.spec.ts +22 -22
- package/src/index.ts +10 -2
- package/src/keyboard-operable.spec.ts +6 -5
- package/src/menu-actions.spec.ts +6 -1
- package/src/namespace/gio.ts +19 -2
- package/src/shared-tree-builder.ts +6 -1
- package/src/split-button.spec.ts +25 -8
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
// is on, and no menubar at all when it is off.
|
|
10
10
|
//
|
|
11
11
|
// A BROWSER HAS NO GtkApplication, so the model is handed to the window instead of to
|
|
12
|
-
// an application that does not exist here. `menu-model` is the same
|
|
13
|
-
// ADR
|
|
14
|
-
//
|
|
12
|
+
// an application that does not exist here. `menu-model` is the same real `Gio.MenuModel`
|
|
13
|
+
// ADR 0128 gave `<gtk-menu-button>` and `<adw-split-button>`, taken through the same
|
|
14
|
+
// {@link MenuSlot} door, so one menu is written once. A menubar is a row of
|
|
15
15
|
// MENU LINKS, which is what `Adw.Application`'s menubar is documented to be, so a
|
|
16
16
|
// section is inlined and a bare item becomes a plain button — `flattenMenu` is the
|
|
17
17
|
// walk that decides both.
|
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
// show-menubar (boolean — Gtk.ApplicationWindow:show-menubar; the bar is drawn only
|
|
21
21
|
// while this is set AND the model has something in it, exactly as GTK draws no bar
|
|
22
22
|
// for an application that installed none)
|
|
23
|
-
// menu-model (the
|
|
24
|
-
// GtkApplication would own;
|
|
23
|
+
// menu-model (the menu's JSON, parsed into the model this window owns — the
|
|
24
|
+
// stand-in for the model a GtkApplication would own; `<gtk-menu-button menu-model>`'s
|
|
25
|
+
// own spelling, and a mutation of a held model redraws the bar)
|
|
25
26
|
//
|
|
26
27
|
// Extends <gtk-application-window>, as Adw.ApplicationWindow extends Gtk.ApplicationWindow,
|
|
27
28
|
// so the Gtk.Window state members come with it. The content property, the size request, the
|
|
@@ -33,22 +34,17 @@
|
|
|
33
34
|
// Copyright (c) 2021-2023 Alice Mikhaylenko / Purism SPC (libadwaita). LGPLv2.1+
|
|
34
35
|
// Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
|
|
35
36
|
|
|
36
|
-
import {
|
|
37
|
-
flattenMenu,
|
|
38
|
-
normalizeMenuModel,
|
|
39
|
-
parseMenuModel,
|
|
40
|
-
type AdwMenuInput,
|
|
41
|
-
type AdwMenuModel,
|
|
42
|
-
} from '@gjsify/adwaita-core';
|
|
37
|
+
import { buildMenu, flattenMenu, type AdwMenuModel } from '@gjsify/adwaita-core';
|
|
43
38
|
|
|
44
39
|
import type { AdwBreakpoint } from '@gjsify/adwaita-core';
|
|
45
40
|
import type { AdwSlot } from '../slotted-children.js';
|
|
46
41
|
import { AdwWindowFeatures } from './adw-window-features.js';
|
|
42
|
+
import { MenuSlot } from './menu-slot.js';
|
|
47
43
|
import { GtkApplicationWindow } from './gtk-application-window.js';
|
|
48
44
|
|
|
49
45
|
export class AdwApplicationWindow extends GtkApplicationWindow {
|
|
50
46
|
private readonly _features = new AdwWindowFeatures(this);
|
|
51
|
-
private
|
|
47
|
+
private readonly _slot = new MenuSlot(() => this._renderMenubar());
|
|
52
48
|
private _menubarEl: HTMLDivElement | null = null;
|
|
53
49
|
|
|
54
50
|
// The base's list BY NAME, not `super.observedAttributes`: `AdwPasswordEntryRow`
|
|
@@ -110,12 +106,12 @@ export class AdwApplicationWindow extends GtkApplicationWindow {
|
|
|
110
106
|
}
|
|
111
107
|
|
|
112
108
|
/** The menubar's model — what a GtkApplication would own here. */
|
|
113
|
-
get menuModel(): AdwMenuModel {
|
|
114
|
-
return this.
|
|
109
|
+
get menuModel(): AdwMenuModel | null {
|
|
110
|
+
return this._slot.model;
|
|
115
111
|
}
|
|
116
112
|
|
|
117
|
-
set menuModel(value:
|
|
118
|
-
this.
|
|
113
|
+
set menuModel(value: AdwMenuModel | string | null) {
|
|
114
|
+
this._slot.write(value);
|
|
119
115
|
this._renderMenubar();
|
|
120
116
|
}
|
|
121
117
|
|
|
@@ -145,7 +141,7 @@ export class AdwApplicationWindow extends GtkApplicationWindow {
|
|
|
145
141
|
// The attribute is the declarative form and it wins when it changes — the same
|
|
146
142
|
// precedence `<gtk-menu-button>` gives `menu-model`, through the same parser.
|
|
147
143
|
if (name === 'menu-model') {
|
|
148
|
-
this.
|
|
144
|
+
this._slot.writeMarkup(this.getAttribute('menu-model'));
|
|
149
145
|
this._renderMenubar();
|
|
150
146
|
}
|
|
151
147
|
}
|
|
@@ -197,16 +193,16 @@ export class AdwApplicationWindow extends GtkApplicationWindow {
|
|
|
197
193
|
bar.replaceChildren();
|
|
198
194
|
// GTK draws no menubar for an application that installed none, so neither does
|
|
199
195
|
// this: both the flag and a model with rows in it are required.
|
|
200
|
-
bar.hidden = !this.showMenubar || this.
|
|
196
|
+
bar.hidden = !this.showMenubar || this._slot.isEmpty;
|
|
201
197
|
|
|
202
|
-
for (const row of flattenMenu(this.
|
|
198
|
+
for (const row of flattenMenu(this._slot.tree)) {
|
|
203
199
|
const node = row.node;
|
|
204
200
|
if (node.kind === 'submenu') {
|
|
205
201
|
// A link is a menu the user OPENS — a button with that menu on it,
|
|
206
202
|
// which is the whole of what a Gtk.PopoverMenuBar row is.
|
|
207
203
|
const button = document.createElement('gtk-menu-button');
|
|
208
204
|
button.setAttribute('menu-title', node.label);
|
|
209
|
-
(button as unknown as { menuModel:
|
|
205
|
+
(button as unknown as { menuModel: AdwMenuModel | null }).menuModel = buildMenu(node.items);
|
|
210
206
|
bar.appendChild(button);
|
|
211
207
|
continue;
|
|
212
208
|
}
|
|
@@ -37,13 +37,8 @@
|
|
|
37
37
|
// Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
|
|
38
38
|
|
|
39
39
|
import {
|
|
40
|
-
ADW_MENU_SURFACE_WEB,
|
|
41
40
|
SplitButtonState,
|
|
42
|
-
assertMenuRenderable,
|
|
43
41
|
isSplitButtonDirection,
|
|
44
|
-
menuRefusals,
|
|
45
|
-
normalizeMenuModel,
|
|
46
|
-
parseMenuModel,
|
|
47
42
|
resolveDropdownTooltip,
|
|
48
43
|
splitButtonArrowIcon,
|
|
49
44
|
splitButtonPopupDirection,
|
|
@@ -52,13 +47,13 @@ import {
|
|
|
52
47
|
import type {
|
|
53
48
|
AdwArrowIcon,
|
|
54
49
|
AdwMenuActions,
|
|
55
|
-
AdwMenuInput,
|
|
56
50
|
AdwMenuModel,
|
|
57
51
|
SplitButtonChange,
|
|
58
52
|
SplitButtonDirection,
|
|
59
53
|
} from '@gjsify/adwaita-core';
|
|
60
54
|
import { activateElementMenuItem, menuActionsOfElement } from '../application.js';
|
|
61
55
|
|
|
56
|
+
import { MenuSlot } from './menu-slot.js';
|
|
62
57
|
import { PopoverMenuView } from './popover-menu.js';
|
|
63
58
|
|
|
64
59
|
// SIDE-EFFECT import, deliberately separate from the type import below: it is
|
|
@@ -121,6 +116,15 @@ const POPOVER_POSITIONS = {
|
|
|
121
116
|
|
|
122
117
|
export class AdwSplitButton extends WidgetElement {
|
|
123
118
|
private readonly _state = new SplitButtonState();
|
|
119
|
+
/**
|
|
120
|
+
* The model's door, tree snapshot and `items-changed` subscription (ADR 0128). The
|
|
121
|
+
* STATE still owns the menu as a property — `dropdownEnabled` is derived from it and
|
|
122
|
+
* the notify order is libadwaita's — so every write here goes through the slot FIRST
|
|
123
|
+
* (which is what refuses it and snapshots it) and is then handed to the state.
|
|
124
|
+
*/
|
|
125
|
+
private readonly _slot = new MenuSlot(() => {
|
|
126
|
+
if (this._initialized) this._render();
|
|
127
|
+
});
|
|
124
128
|
private _actionEl!: HTMLButtonElement;
|
|
125
129
|
private _dropdownEl!: HTMLButtonElement;
|
|
126
130
|
private _arrowEl!: GtkImage;
|
|
@@ -180,60 +184,29 @@ export class AdwSplitButton extends WidgetElement {
|
|
|
180
184
|
this.removeAttribute('label');
|
|
181
185
|
}
|
|
182
186
|
|
|
183
|
-
/** The menu, normalised (ADR 0042). Empty when the dropdown has none. */
|
|
184
|
-
get menuModel(): AdwMenuModel {
|
|
185
|
-
return this._state.menuModel ?? [];
|
|
186
|
-
}
|
|
187
|
-
|
|
188
187
|
/**
|
|
189
|
-
*
|
|
190
|
-
* descriptors, sections, submenus.
|
|
188
|
+
* The menu the dropdown pops up, by identity, or `null` when it has none (ADR 0128).
|
|
191
189
|
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
190
|
+
* `null` is the ONLY spelling of "no menu" now: an empty model is a menu, so an empty
|
|
191
|
+
* one leaves the dropdown enabled exactly as it does in GTK, where a non-null model
|
|
192
|
+
* still builds a popover (measured, `MENU_MODEL_DOOR_VECTORS`).
|
|
195
193
|
*/
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
// had rather than one it cannot draw.
|
|
199
|
-
this._state.setMenuModel(this._acceptMenu(value));
|
|
200
|
-
if (this._initialized) this._render();
|
|
194
|
+
get menuModel(): AdwMenuModel | null {
|
|
195
|
+
return this._state.menuModel;
|
|
201
196
|
}
|
|
202
197
|
|
|
203
198
|
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
* The property setter is not the only way a menu arrives: an attribute is, and the
|
|
207
|
-
* first cut refused only the setter, so a `custom` item written in markup drew
|
|
208
|
-
* exactly the row `assertMenuRenderable` exists to prevent — one that reads like a
|
|
209
|
-
* command and does nothing.
|
|
210
|
-
*
|
|
211
|
-
* THE TWO DOORS REFUSE DIFFERENTLY, and the difference is which side can hear it.
|
|
212
|
-
* A property assignment is a CALL, so it throws and the caller can catch. An
|
|
213
|
-
* attribute is MARKUP, parsed by the browser: a throw from `connectedCallback` is
|
|
214
|
-
* not delivered to whoever appended the element, it is reported as an uncaught
|
|
215
|
-
* page error — measured, it broke `adwaita-upgrade-order.spec.ts`, which counts
|
|
216
|
-
* exactly those. Nobody can handle it and everybody else pays for it. So the
|
|
217
|
-
* attribute path REFUSES THE MENU (no menu, and a menu-less dropdown is
|
|
218
|
-
* insensitive, which is visible) and says why on `console.error`.
|
|
199
|
+
* Take a real model, a JSON string in the portable spelling, or `null`.
|
|
219
200
|
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
201
|
+
* The REFUSAL is at the assignment rather than at render time: a `custom` item names
|
|
202
|
+
* an application widget this surface cannot host, and discovering that as a blank row
|
|
203
|
+
* three interactions later is what the refusal exists to prevent. {@link MenuSlot}
|
|
204
|
+
* owns the door; the state is handed only a menu this surface agreed to draw.
|
|
223
205
|
*/
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
return model;
|
|
229
|
-
}
|
|
230
|
-
const refusals = menuRefusals(model, ADW_MENU_SURFACE_WEB);
|
|
231
|
-
if (refusals.length === 0) return model;
|
|
232
|
-
// `console.error` is the browser's own channel for "this is wrong, the page
|
|
233
|
-
// continues" — and the alternative here is a row that lies.
|
|
234
|
-
console.error(`${ADW_MENU_SURFACE_WEB.name} cannot render this menu, so it has none:`);
|
|
235
|
-
for (const refusal of refusals) console.error(` [${refusal.path.join('.')}] ${refusal.message}`);
|
|
236
|
-
return [];
|
|
206
|
+
set menuModel(value: AdwMenuModel | string | null) {
|
|
207
|
+
this._slot.write(value);
|
|
208
|
+
this._state.setMenuModel(this._slot.model);
|
|
209
|
+
if (this._initialized) this._render();
|
|
237
210
|
}
|
|
238
211
|
|
|
239
212
|
/**
|
|
@@ -387,7 +360,8 @@ export class AdwSplitButton extends WidgetElement {
|
|
|
387
360
|
this._state.setDropdownTooltip(value ?? '');
|
|
388
361
|
break;
|
|
389
362
|
case 'menu-model':
|
|
390
|
-
this.
|
|
363
|
+
this._slot.writeMarkup(value);
|
|
364
|
+
this._state.setMenuModel(this._slot.model);
|
|
391
365
|
break;
|
|
392
366
|
default:
|
|
393
367
|
break;
|
|
@@ -409,10 +383,12 @@ export class AdwSplitButton extends WidgetElement {
|
|
|
409
383
|
const direction = this.getAttribute('direction');
|
|
410
384
|
if (isSplitButtonDirection(direction)) this._state.setDirection(direction);
|
|
411
385
|
this._state.setDropdownTooltip(this.getAttribute('dropdown-tooltip') ?? '');
|
|
412
|
-
// The `menuModel` property may have been set before the element upgraded;
|
|
413
|
-
//
|
|
386
|
+
// The `menuModel` property may have been set before the element upgraded; the
|
|
387
|
+
// attribute only seeds a widget that has NO menu — which since ADR 0128 means a
|
|
388
|
+
// null one, an empty model being a menu an author may have meant.
|
|
414
389
|
if (this._state.menuModel === null) {
|
|
415
|
-
this.
|
|
390
|
+
this._slot.writeMarkup(this.getAttribute('menu-model'));
|
|
391
|
+
this._state.setMenuModel(this._slot.model);
|
|
416
392
|
}
|
|
417
393
|
}
|
|
418
394
|
|
|
@@ -528,9 +504,9 @@ export class AdwSplitButton extends WidgetElement {
|
|
|
528
504
|
|
|
529
505
|
private _renderMenu(): void {
|
|
530
506
|
this._menuEl.open = this._state.open;
|
|
531
|
-
const
|
|
532
|
-
this._menuView.
|
|
533
|
-
this._menuView.setActions(this._actions ?? menuActionsOfElement(this,
|
|
507
|
+
const tree = this._slot.tree;
|
|
508
|
+
this._menuView.setMenuTree(tree);
|
|
509
|
+
this._menuView.setActions(this._actions ?? menuActionsOfElement(this, tree));
|
|
534
510
|
this._menuView.render();
|
|
535
511
|
}
|
|
536
512
|
}
|
|
@@ -46,8 +46,9 @@
|
|
|
46
46
|
//
|
|
47
47
|
// `view` is a widget REFERENCE (`Adw.TabView`), so the attribute is the element id
|
|
48
48
|
// `<adw-tab-button view>` also takes, while `view`/`setView` take the element.
|
|
49
|
-
// `secondary-menu` is a `GMenuModel
|
|
50
|
-
// attribute takes its JSON, which is `<gtk-menu-button menu-model>`'s own
|
|
49
|
+
// `secondary-menu` is a `GMenuModel` and takes a real one (ADR 0128): the property takes
|
|
50
|
+
// the model, the attribute takes its JSON, which is `<gtk-menu-button menu-model>`'s own
|
|
51
|
+
// spelling, and {@link MenuSlot} is the door both go through.
|
|
51
52
|
//
|
|
52
53
|
// A11Y: the overview is a `role="grid"` of `role="gridcell"` thumbnails under a
|
|
53
54
|
// header bar, and the pinned grid is a second grid — C's `AdwTabGrid` is a
|
|
@@ -68,16 +69,9 @@
|
|
|
68
69
|
// Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the icon,
|
|
69
70
|
// indicator and portrait nodes are <gtk-image> and the grids are <div role="grid">.
|
|
70
71
|
|
|
71
|
-
import {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
tabSearchMatches,
|
|
75
|
-
tabTooltip,
|
|
76
|
-
type AdwMenuInput,
|
|
77
|
-
type AdwMenuModel,
|
|
78
|
-
} from '@gjsify/adwaita-core';
|
|
79
|
-
import { normalizeMenuModel } from '@gjsify/adwaita-core';
|
|
80
|
-
import { assertMenuRenderable, ADW_MENU_SURFACE_WEB } from '@gjsify/adwaita-core';
|
|
72
|
+
import { gridColumns, gridNavigate, tabSearchMatches, tabTooltip, type AdwMenuModel } from '@gjsify/adwaita-core';
|
|
73
|
+
|
|
74
|
+
import { MenuSlot } from './menu-slot.js';
|
|
81
75
|
|
|
82
76
|
import type { AdwTabView, AdwTabViewPage } from './adw-tab-view.js';
|
|
83
77
|
import { createGtkImage, type GtkImage } from './gtk-image.js';
|
|
@@ -119,7 +113,7 @@ export class AdwTabOverview extends WidgetElement {
|
|
|
119
113
|
private readonly _thumbs = new Map<string, Thumbnail>();
|
|
120
114
|
|
|
121
115
|
private _view: AdwTabView | null = null;
|
|
122
|
-
private
|
|
116
|
+
private readonly _slot = new MenuSlot(() => this._applyMenu());
|
|
123
117
|
private _searchActive = false;
|
|
124
118
|
private _initialized = false;
|
|
125
119
|
/** The last visibility actually painted, so `notify::open` fires on a real change. */
|
|
@@ -225,16 +219,14 @@ export class AdwTabOverview extends WidgetElement {
|
|
|
225
219
|
}
|
|
226
220
|
|
|
227
221
|
/** `Adw.TabOverview:secondary-menu` — the portal menu of extra actions. */
|
|
228
|
-
get secondaryMenu(): AdwMenuModel {
|
|
229
|
-
return this.
|
|
222
|
+
get secondaryMenu(): AdwMenuModel | null {
|
|
223
|
+
return this._slot.model;
|
|
230
224
|
}
|
|
231
225
|
|
|
232
|
-
set secondaryMenu(value:
|
|
233
|
-
const model = normalizeMenuModel(value);
|
|
226
|
+
set secondaryMenu(value: AdwMenuModel | string | null) {
|
|
234
227
|
// The property setter is a CALL, so a `custom` item throws here where the caller
|
|
235
228
|
// can catch it — `<gtk-menu-button>`'s own rule, which keeps the two doors honest.
|
|
236
|
-
|
|
237
|
-
this._model = model;
|
|
229
|
+
this._slot.write(value);
|
|
238
230
|
this._applyMenu();
|
|
239
231
|
}
|
|
240
232
|
|
|
@@ -398,7 +390,8 @@ export class AdwTabOverview extends WidgetElement {
|
|
|
398
390
|
|
|
399
391
|
const declaredMenu = this.getAttribute('secondary-menu');
|
|
400
392
|
if (declaredMenu !== null) {
|
|
401
|
-
this.
|
|
393
|
+
this._slot.writeMarkup(declaredMenu);
|
|
394
|
+
this._applyMenu();
|
|
402
395
|
this.removeAttribute('secondary-menu');
|
|
403
396
|
}
|
|
404
397
|
|
|
@@ -423,11 +416,8 @@ export class AdwTabOverview extends WidgetElement {
|
|
|
423
416
|
if (value === null) return;
|
|
424
417
|
// An attribute is MARKUP: a `custom` item here is reported, not thrown
|
|
425
418
|
// (adw-sidebar/`gtk-menu-button`'s rule for the same two doors).
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
} catch (error) {
|
|
429
|
-
console.error(`<adw-tab-overview secondary-menu> is not valid JSON: ${String(error)}`);
|
|
430
|
-
}
|
|
419
|
+
this._slot.writeMarkup(value);
|
|
420
|
+
this._applyMenu();
|
|
431
421
|
return;
|
|
432
422
|
}
|
|
433
423
|
case 'enable-new-tab':
|
|
@@ -557,7 +547,7 @@ export class AdwTabOverview extends WidgetElement {
|
|
|
557
547
|
* slot is where an application puts its own controls.
|
|
558
548
|
*/
|
|
559
549
|
private _applyHeaderBar(): void {
|
|
560
|
-
const hasMenu = this.
|
|
550
|
+
const hasMenu = !this._slot.isEmpty;
|
|
561
551
|
this._headerEl.hidden = !(
|
|
562
552
|
this.enableSearch ||
|
|
563
553
|
hasMenu ||
|
|
@@ -572,8 +562,8 @@ export class AdwTabOverview extends WidgetElement {
|
|
|
572
562
|
}
|
|
573
563
|
|
|
574
564
|
private _applyMenu(): void {
|
|
575
|
-
const view = this._menuBtn as HTMLElement & { menuModel?: AdwMenuModel };
|
|
576
|
-
if (this.
|
|
565
|
+
const view = this._menuBtn as HTMLElement & { menuModel?: AdwMenuModel | null };
|
|
566
|
+
if (this._slot.model !== null) view.menuModel = this._slot.model;
|
|
577
567
|
this._applyHeaderBar();
|
|
578
568
|
}
|
|
579
569
|
|
|
@@ -9,8 +9,12 @@
|
|
|
9
9
|
// `normalizeIconName` guard is what keeps a multi-token `icon-name="a b"` from
|
|
10
10
|
// shipping a stray CSS class — for the button icon and for every menu entry's.
|
|
11
11
|
//
|
|
12
|
-
// The `menu-model`
|
|
13
|
-
// items, sections and submenus, with the same attributes GTK reads.
|
|
12
|
+
// The `menu-model` ATTRIBUTE is a JSON array in the portable menu model (ADR 0042) —
|
|
13
|
+
// items, sections and submenus, with the same attributes GTK reads. The `menuModel`
|
|
14
|
+
// PROPERTY is a real `Gio.MenuModel` (ADR 0128), which is what GTK's own property is:
|
|
15
|
+
// {@link MenuSlot} is the door between the two, and it is what hears a menu mutate
|
|
16
|
+
// after it was handed over; a write of the property or the attribute fires
|
|
17
|
+
// `notify::menu-model` (CustomEvent, bubbles, detail `{ menuModel }`). Choosing an item
|
|
14
18
|
// closes the popover and fires `menu-item-activated` (CustomEvent, bubbles, detail
|
|
15
19
|
// `{ id, label, path }`) with `id` falling back to `label`, matching the NS twin. The
|
|
16
20
|
// rows themselves are `PopoverMenuView`'s, shared with `<adw-split-button>`: the split
|
|
@@ -24,19 +28,11 @@
|
|
|
24
28
|
// Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
|
|
25
29
|
// Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
|
|
26
30
|
|
|
27
|
-
import {
|
|
28
|
-
ADW_MENU_SURFACE_WEB,
|
|
29
|
-
assertMenuRenderable,
|
|
30
|
-
isSplitButtonDirection,
|
|
31
|
-
menuRefusals,
|
|
32
|
-
menuButtonPopupDirection,
|
|
33
|
-
menuItemAt,
|
|
34
|
-
normalizeMenuModel,
|
|
35
|
-
parseMenuModel,
|
|
36
|
-
} from '@gjsify/adwaita-core';
|
|
31
|
+
import { isSplitButtonDirection, menuButtonPopupDirection, menuItemAt } from '@gjsify/adwaita-core';
|
|
37
32
|
import { activateElementMenuItem, menuActionsOfElement } from '../application.js';
|
|
38
|
-
import type { AdwMenuActions,
|
|
33
|
+
import type { AdwMenuActions, AdwMenuModel, SplitButtonDirection } from '@gjsify/adwaita-core';
|
|
39
34
|
|
|
35
|
+
import { MenuSlot } from './menu-slot.js';
|
|
40
36
|
import { PopoverMenuView } from './popover-menu.js';
|
|
41
37
|
|
|
42
38
|
// SIDE-EFFECT import, deliberately separate from the type import below: it guarantees
|
|
@@ -69,7 +65,10 @@ const POPOVER_POSITIONS = {
|
|
|
69
65
|
export class GtkMenuButton extends WidgetElement {
|
|
70
66
|
private _buttonEl!: HTMLButtonElement;
|
|
71
67
|
private _popoverEl!: GtkPopover;
|
|
72
|
-
|
|
68
|
+
/** The model, its tree snapshot and its `items-changed` subscription (ADR 0128). */
|
|
69
|
+
private readonly _slot = new MenuSlot(() => {
|
|
70
|
+
if (this._initialized) this._renderMenu();
|
|
71
|
+
});
|
|
73
72
|
private _actions: AdwMenuActions | null = null;
|
|
74
73
|
private _menuTitle = '';
|
|
75
74
|
private _initialized = false;
|
|
@@ -84,74 +83,46 @@ export class GtkMenuButton extends WidgetElement {
|
|
|
84
83
|
return this._popoverEl?.open ?? false;
|
|
85
84
|
}
|
|
86
85
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
/**
|
|
87
|
+
* The menu this button pops up, BY IDENTITY — the model itself, `null` when there is
|
|
88
|
+
* none, exactly as `gtk_menu_button_get_menu_model` answers (ADR 0128).
|
|
89
|
+
*
|
|
90
|
+
* It no longer answers an array, and `[]` is no longer how "no menu" is spelled: an
|
|
91
|
+
* EMPTY model is a menu that GTK still builds a popover for, and `null` is the one
|
|
92
|
+
* spelling of its absence (measured, `MENU_MODEL_DOOR_VECTORS`).
|
|
93
|
+
*/
|
|
94
|
+
get menuModel(): AdwMenuModel | null {
|
|
95
|
+
return this._slot.model;
|
|
90
96
|
}
|
|
91
97
|
|
|
92
98
|
/**
|
|
93
|
-
*
|
|
94
|
-
* assignment, for a `custom` item this surface cannot host —
|
|
95
|
-
* note on
|
|
99
|
+
* Take a real model, a JSON string in the portable spelling, or `null`. Refused HERE,
|
|
100
|
+
* at the assignment, for a `custom` item this surface cannot host — {@link MenuSlot}
|
|
101
|
+
* owns both doors and the note on why they refuse differently.
|
|
96
102
|
*/
|
|
97
|
-
set menuModel(value:
|
|
98
|
-
this.
|
|
103
|
+
set menuModel(value: AdwMenuModel | string | null) {
|
|
104
|
+
this._slot.write(value);
|
|
99
105
|
if (this._initialized) this._renderMenu();
|
|
106
|
+
this._notifyModel();
|
|
100
107
|
}
|
|
101
108
|
|
|
102
109
|
/** `Gtk.MenuButton:menu-model` — the GIR spelling of {@link menuModel}. */
|
|
103
|
-
get menu_model(): AdwMenuModel {
|
|
110
|
+
get menu_model(): AdwMenuModel | null {
|
|
104
111
|
return this.menuModel;
|
|
105
112
|
}
|
|
106
113
|
|
|
107
|
-
set menu_model(value:
|
|
114
|
+
set menu_model(value: AdwMenuModel | string | null) {
|
|
108
115
|
this.menuModel = value;
|
|
109
116
|
}
|
|
110
117
|
|
|
111
|
-
get_menu_model(): AdwMenuModel {
|
|
118
|
+
get_menu_model(): AdwMenuModel | null {
|
|
112
119
|
return this.menuModel;
|
|
113
120
|
}
|
|
114
121
|
|
|
115
|
-
set_menu_model(value:
|
|
122
|
+
set_menu_model(value: AdwMenuModel | string | null): void {
|
|
116
123
|
this.menuModel = value;
|
|
117
124
|
}
|
|
118
125
|
|
|
119
|
-
/**
|
|
120
|
-
* Normalise, REFUSE, store — the one door every menu comes through.
|
|
121
|
-
*
|
|
122
|
-
* The property setter is not the only way a menu arrives: an attribute is, and the
|
|
123
|
-
* first cut refused only the setter, so a `custom` item written in markup drew
|
|
124
|
-
* exactly the row `assertMenuRenderable` exists to prevent — one that reads like a
|
|
125
|
-
* command and does nothing.
|
|
126
|
-
*
|
|
127
|
-
* THE TWO DOORS REFUSE DIFFERENTLY, and the difference is which side can hear it.
|
|
128
|
-
* A property assignment is a CALL, so it throws and the caller can catch. An
|
|
129
|
-
* attribute is MARKUP, parsed by the browser: a throw from `connectedCallback` is
|
|
130
|
-
* not delivered to whoever appended the element, it is reported as an uncaught
|
|
131
|
-
* page error — measured, it broke `adwaita-upgrade-order.spec.ts`, which counts
|
|
132
|
-
* exactly those. Nobody can handle it and everybody else pays for it. So the
|
|
133
|
-
* attribute path REFUSES THE MENU (no menu, and a menu-less dropdown is
|
|
134
|
-
* insensitive, which is visible) and says why on `console.error`.
|
|
135
|
-
*
|
|
136
|
-
* A REFUSAL AND A TYPO ARE STILL DIFFERENT THINGS. `parseMenuModel` is total
|
|
137
|
-
* because malformed JSON is an author slip and must not stop the element
|
|
138
|
-
* upgrading; `custom` is well-formed, deliberate, and unhonourable here.
|
|
139
|
-
*/
|
|
140
|
-
private _acceptMenu(input: AdwMenuInput, strict = true): AdwMenuModel {
|
|
141
|
-
const model = normalizeMenuModel(input);
|
|
142
|
-
if (strict) {
|
|
143
|
-
assertMenuRenderable(model, ADW_MENU_SURFACE_WEB);
|
|
144
|
-
return model;
|
|
145
|
-
}
|
|
146
|
-
const refusals = menuRefusals(model, ADW_MENU_SURFACE_WEB);
|
|
147
|
-
if (refusals.length === 0) return model;
|
|
148
|
-
// `console.error` is the browser's own channel for "this is wrong, the page
|
|
149
|
-
// continues" — and the alternative here is a row that lies.
|
|
150
|
-
console.error(`${ADW_MENU_SURFACE_WEB.name} cannot render this menu, so it has none:`);
|
|
151
|
-
for (const refusal of refusals) console.error(` [${refusal.path.join('.')}] ${refusal.message}`);
|
|
152
|
-
return [];
|
|
153
|
-
}
|
|
154
|
-
|
|
155
126
|
/**
|
|
156
127
|
* What the action group publishes about the actions this menu names — the portable
|
|
157
128
|
* stand-in for a `GActionGroup`, and the only source of a menu's enabled/checked
|
|
@@ -212,7 +183,7 @@ export class GtkMenuButton extends WidgetElement {
|
|
|
212
183
|
this._popoverEl.subscribe((open) => this._onPopoverToggled(open));
|
|
213
184
|
|
|
214
185
|
this._menuView = new PopoverMenuView(this._popoverEl, 'adw-menu-button', (path) => {
|
|
215
|
-
const item = menuItemAt(this.
|
|
186
|
+
const item = menuItemAt(this._slot.tree, path);
|
|
216
187
|
if (item === null) return;
|
|
217
188
|
this._popoverEl.popdown();
|
|
218
189
|
this._buttonEl.focus();
|
|
@@ -229,12 +200,14 @@ export class GtkMenuButton extends WidgetElement {
|
|
|
229
200
|
this._buttonEl.addEventListener('click', () => {
|
|
230
201
|
if (this.hasAttribute('disabled')) return;
|
|
231
202
|
// Nothing to show is not a menu — never open an empty popover.
|
|
232
|
-
if (!this._popoverEl.open && this.
|
|
203
|
+
if (!this._popoverEl.open && this._slot.isEmpty) return;
|
|
233
204
|
this._popoverEl.open = !this._popoverEl.open;
|
|
234
205
|
});
|
|
235
206
|
|
|
236
|
-
// Seed the menu from the attribute if the property was not set
|
|
237
|
-
|
|
207
|
+
// Seed the menu from the attribute if the property was not set — and "not set"
|
|
208
|
+
// is now the ABSENCE of a model, not an empty one, since an empty model is a
|
|
209
|
+
// menu an author may have handed over deliberately.
|
|
210
|
+
if (this._slot.model === null) this._slot.writeMarkup(this.getAttribute('menu-model'));
|
|
238
211
|
// The flat default is set only now the DOM exists, so the observed-attribute
|
|
239
212
|
// callback (which renders) never runs before `_buttonEl` is created.
|
|
240
213
|
if (!this.hasAttribute('flat') && !this.hasAttribute('circular')) this.setAttribute('flat', '');
|
|
@@ -244,25 +217,14 @@ export class GtkMenuButton extends WidgetElement {
|
|
|
244
217
|
attributeChangedCallback(name: string) {
|
|
245
218
|
if (!this._initialized || !this._buttonEl) return;
|
|
246
219
|
if (name === 'menu-model') {
|
|
247
|
-
this.
|
|
220
|
+
this._slot.writeMarkup(this.getAttribute('menu-model'));
|
|
248
221
|
this._renderMenu();
|
|
222
|
+
this._notifyModel();
|
|
249
223
|
return;
|
|
250
224
|
}
|
|
251
225
|
this._render();
|
|
252
226
|
}
|
|
253
227
|
|
|
254
|
-
/**
|
|
255
|
-
* The `menu-model` attribute, through the core parser both elements share.
|
|
256
|
-
*
|
|
257
|
-
* The copy this replaces was weaker in a way markup can reach: it kept `id` and
|
|
258
|
-
* `icon` WHATEVER their runtime type, so `{"label":"Open","id":7}` produced an
|
|
259
|
-
* entry whose `id` is a number in a field typed `string` — and `id` is what the
|
|
260
|
-
* activation event reports. The core parser keeps only string-typed attributes.
|
|
261
|
-
*/
|
|
262
|
-
private _parseMenuAttr(): AdwMenuModel {
|
|
263
|
-
return this._acceptMenu(parseMenuModel(this.getAttribute('menu-model')), false);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
228
|
private _onPopoverToggled(open: boolean): void {
|
|
267
229
|
this.classList.toggle('active', open);
|
|
268
230
|
this._buttonEl.setAttribute('aria-expanded', String(open));
|
|
@@ -295,15 +257,31 @@ export class GtkMenuButton extends WidgetElement {
|
|
|
295
257
|
|
|
296
258
|
/** The registry's answer, unless the application wrote `actions` itself (ADR 0097 § 2). */
|
|
297
259
|
private _refreshActions(): void {
|
|
298
|
-
this._menuView.setActions(this._actions ?? menuActionsOfElement(this, this.
|
|
260
|
+
this._menuView.setActions(this._actions ?? menuActionsOfElement(this, this._slot.tree));
|
|
299
261
|
this._menuView.render();
|
|
300
262
|
}
|
|
301
263
|
|
|
264
|
+
/**
|
|
265
|
+
* `gtk_menu_button_set_menu_model` notifies UNCONDITIONALLY — no identity guard, so the
|
|
266
|
+
* same model written twice notifies twice and `null` over `null` notifies as well
|
|
267
|
+
* (measured, `MENU_MODEL_NOTIFY_VECTORS`). Its `Adw.SplitButton` sibling guards on
|
|
268
|
+
* pointer equality, and the two disagree on purpose: a port with one rule for both is
|
|
269
|
+
* wrong about one of them.
|
|
270
|
+
*
|
|
271
|
+
* A MUTATION of the held model is not a write and notifies nothing — it arrives over
|
|
272
|
+
* `items-changed`, where it redraws the rows and stops.
|
|
273
|
+
*/
|
|
274
|
+
private _notifyModel(): void {
|
|
275
|
+
this.dispatchEvent(
|
|
276
|
+
new CustomEvent('notify::menu-model', { bubbles: true, detail: { menuModel: this._slot.model } }),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
302
280
|
private _renderMenu(): void {
|
|
303
|
-
this._menuView.
|
|
281
|
+
this._menuView.setMenuTree(this._slot.tree);
|
|
304
282
|
this._menuView.setTitle(this.getAttribute('menu-title') ?? this._menuTitle);
|
|
305
283
|
this._menuView.render();
|
|
306
|
-
if (this.
|
|
284
|
+
if (this._slot.isEmpty) this._popoverEl.popdown();
|
|
307
285
|
}
|
|
308
286
|
}
|
|
309
287
|
|