@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.
Files changed (36) hide show
  1. package/lib/types/application.d.ts +3 -3
  2. package/lib/types/elements/adw-application-window.d.ts +4 -4
  3. package/lib/types/elements/adw-split-button.d.ts +19 -28
  4. package/lib/types/elements/adw-tab-overview.d.ts +4 -4
  5. package/lib/types/elements/gtk-menu-button.d.ts +30 -42
  6. package/lib/types/elements/gtk-popover-bin.d.ts +18 -11
  7. package/lib/types/elements/gtk-popover-menu-bar.d.ts +15 -8
  8. package/lib/types/elements/gtk-popover-menu.d.ts +13 -15
  9. package/lib/types/elements/menu-slot.d.ts +49 -0
  10. package/lib/types/elements/popover-menu.d.ts +11 -7
  11. package/lib/types/index.d.ts +2 -1
  12. package/lib/types/namespace/gio.d.ts +1 -1
  13. package/package.json +13 -12
  14. package/src/adw-application-window.spec.ts +5 -2
  15. package/src/application.ts +4 -4
  16. package/src/elements/adw-application-window.ts +17 -21
  17. package/src/elements/adw-split-button.ts +35 -59
  18. package/src/elements/adw-tab-overview.ts +18 -28
  19. package/src/elements/gtk-menu-button.ts +60 -82
  20. package/src/elements/gtk-popover-bin.ts +32 -38
  21. package/src/elements/gtk-popover-menu-bar.ts +41 -46
  22. package/src/elements/gtk-popover-menu.ts +35 -54
  23. package/src/elements/menu-slot.ts +139 -0
  24. package/src/elements/popover-menu.ts +26 -16
  25. package/src/gio-menu.spec.ts +173 -34
  26. package/src/gtk-image.spec.ts +3 -2
  27. package/src/gtk-menu-button.spec.ts +19 -16
  28. package/src/gtk-popover-bin.spec.ts +6 -5
  29. package/src/gtk-popover-menu-bar.spec.ts +11 -6
  30. package/src/gtk-popover-menu.spec.ts +22 -22
  31. package/src/index.ts +10 -2
  32. package/src/keyboard-operable.spec.ts +6 -5
  33. package/src/menu-actions.spec.ts +6 -1
  34. package/src/namespace/gio.ts +19 -2
  35. package/src/shared-tree-builder.ts +6 -1
  36. 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 portable menu value
13
- // ADR 0042 gave `<gtk-menu-button>` and `<adw-split-button>` (a JSON array), and it is
14
- // read through the same core parser, so one menu is written once. A menubar is a row of
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 portable menu model, JSON — the stand-in for the model a
24
- // GtkApplication would own; the same value `<gtk-menu-button menu-model>` takes)
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 _model: AdwMenuModel = [];
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._model;
109
+ get menuModel(): AdwMenuModel | null {
110
+ return this._slot.model;
115
111
  }
116
112
 
117
- set menuModel(value: AdwMenuInput) {
118
- this._model = normalizeMenuModel(value);
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._model = parseMenuModel(this.getAttribute('menu-model'));
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._model.length === 0;
196
+ bar.hidden = !this.showMenubar || this._slot.isEmpty;
201
197
 
202
- for (const row of flattenMenu(this._model)) {
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: AdwMenuInput }).menuModel = node.items;
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
- * Set the menu from anything the portable model accepts — a bare `string[]`, item
190
- * descriptors, sections, submenus.
188
+ * The menu the dropdown pops up, by identity, or `null` when it has none (ADR 0128).
191
189
  *
192
- * The REFUSAL is here rather than at render time: a `custom` item names an
193
- * application widget this surface cannot host, and discovering that as a blank row
194
- * three interactions later is what {@link assertMenuRenderable} exists to prevent.
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
- set menuModel(value: AdwMenuInput) {
197
- // Refused BEFORE it is stored, so a throw leaves the widget showing the menu it
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
- * Normalise, REFUSE, store — the one door every menu comes through.
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
- * A REFUSAL AND A TYPO ARE STILL DIFFERENT THINGS. `parseMenuModel` is total
221
- * because malformed JSON is an author slip and must not stop the element
222
- * upgrading; `custom` is well-formed, deliberate, and unhonourable here.
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
- private _acceptMenu(input: AdwMenuInput, strict = true): AdwMenuModel {
225
- const model = normalizeMenuModel(input);
226
- if (strict) {
227
- assertMenuRenderable(model, ADW_MENU_SURFACE_WEB);
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._state.setMenuModel(this._acceptMenu(parseMenuModel(value), false));
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
- // the attribute only seeds an otherwise empty menu.
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._state.setMenuModel(this._acceptMenu(parseMenuModel(this.getAttribute('menu-model')), false));
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 model = this._state.menuModel ?? [];
532
- this._menuView.setMenuModel(model);
533
- this._menuView.setActions(this._actions ?? menuActionsOfElement(this, model));
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`: the property takes the portable model and the
50
- // attribute takes its JSON, which is `<gtk-menu-button menu-model>`'s own spelling.
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
- gridColumns,
73
- gridNavigate,
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 _model: AdwMenuModel = [];
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._model;
222
+ get secondaryMenu(): AdwMenuModel | null {
223
+ return this._slot.model;
230
224
  }
231
225
 
232
- set secondaryMenu(value: AdwMenuInput) {
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
- assertMenuRenderable(model, ADW_MENU_SURFACE_WEB);
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.secondaryMenu = JSON.parse(declaredMenu) as AdwMenuInput;
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
- try {
427
- this.secondaryMenu = JSON.parse(value) as AdwMenuInput;
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._model.length > 0;
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._model.length > 0) view.menuModel = this._model;
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` attribute is a JSON array in the portable menu model (ADR 0042) —
13
- // items, sections and submenus, with the same attributes GTK reads. Choosing an item
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, AdwMenuInput, AdwMenuModel, SplitButtonDirection } from '@gjsify/adwaita-core';
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
- private _model: AdwMenuModel = [];
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
- /** The menu, normalised (ADR 0042). Setting rebuilds the popover. */
88
- get menuModel(): AdwMenuModel {
89
- return this._model;
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
- * Set the menu from anything the portable model accepts. Refused HERE, at the
94
- * assignment, for a `custom` item this surface cannot host — see the split button's
95
- * note on the same setter.
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: AdwMenuInput) {
98
- this._model = this._acceptMenu(value);
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: AdwMenuInput) {
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: AdwMenuInput): void {
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._model, path);
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._model.length === 0) return;
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
- if (this._model.length === 0) this._model = this._parseMenuAttr();
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._model = this._parseMenuAttr();
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._model));
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.setMenuModel(this._model);
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._model.length === 0) this._popoverEl.popdown();
284
+ if (this._slot.isEmpty) this._popoverEl.popdown();
307
285
  }
308
286
  }
309
287