@gjsify/adwaita-web 0.64.13-next.283 → 0.64.14

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 (44) 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-bin.d.ts +13 -0
  4. package/lib/types/elements/adw-split-button.d.ts +19 -28
  5. package/lib/types/elements/adw-tab-overview.d.ts +4 -4
  6. package/lib/types/elements/gtk-menu-button.d.ts +30 -42
  7. package/lib/types/elements/gtk-popover-bin.d.ts +18 -11
  8. package/lib/types/elements/gtk-popover-menu-bar.d.ts +15 -8
  9. package/lib/types/elements/gtk-popover-menu.d.ts +13 -15
  10. package/lib/types/elements/menu-slot.d.ts +49 -0
  11. package/lib/types/elements/popover-menu.d.ts +11 -7
  12. package/lib/types/index.d.ts +2 -1
  13. package/lib/types/namespace/gio.d.ts +1 -1
  14. package/lib/types/widget-element.d.ts +8 -2
  15. package/package.json +12 -13
  16. package/src/adw-application-window.spec.ts +5 -2
  17. package/src/adw-bin.spec.ts +20 -0
  18. package/src/application.ts +4 -4
  19. package/src/elements/adw-application-window.ts +17 -21
  20. package/src/elements/adw-bin.ts +20 -1
  21. package/src/elements/adw-split-button.ts +35 -59
  22. package/src/elements/adw-tab-overview.ts +18 -28
  23. package/src/elements/gtk-menu-button.ts +60 -82
  24. package/src/elements/gtk-popover-bin.ts +32 -38
  25. package/src/elements/gtk-popover-menu-bar.ts +41 -46
  26. package/src/elements/gtk-popover-menu.ts +35 -54
  27. package/src/elements/menu-slot.ts +139 -0
  28. package/src/elements/popover-menu.ts +26 -16
  29. package/src/gio-menu.spec.ts +173 -34
  30. package/src/gtk-image.spec.ts +3 -2
  31. package/src/gtk-menu-button.spec.ts +19 -16
  32. package/src/gtk-popover-bin.spec.ts +6 -5
  33. package/src/gtk-popover-menu-bar.spec.ts +11 -6
  34. package/src/gtk-popover-menu.spec.ts +22 -22
  35. package/src/index.ts +10 -2
  36. package/src/keyboard-operable.spec.ts +6 -5
  37. package/src/menu-actions.spec.ts +6 -1
  38. package/src/namespace/gio.ts +19 -2
  39. package/src/pango-markup.spec.ts +11 -0
  40. package/src/pango-markup.ts +5 -2
  41. package/src/shared-tree-builder.ts +6 -1
  42. package/src/split-button.spec.ts +25 -8
  43. package/src/widget-element.ts +28 -7
  44. package/src/widget-members.spec.ts +42 -3
@@ -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
 
@@ -32,7 +32,8 @@
32
32
  // handle-input — boolean, default FALSE — GTK's default. When set, a right click and a
33
33
  // long press open the popover at the pointer.
34
34
  // Properties:
35
- // menuModel — the model, normalised (get/set). Setting it REPLACES any popover.
35
+ // menuModel — the real `Gio.MenuModel`, or `null` (get/set; ADR 0128). Setting it
36
+ // REPLACES any popover.
36
37
  // popoverElement — the `<gtk-popover>` this bin presents (get/set; setting it drops
37
38
  // the model, as GTK does). `GtkPopoverBin`'s own name for it is `popover`,
38
39
  // which `HTMLElement` already spells as a string attribute for the DOM
@@ -60,14 +61,9 @@
60
61
  // Copyright (c) GNOME contributors (GTK, libadwaita). LGPLv2.1+
61
62
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
62
63
 
63
- import {
64
- ADW_MENU_SURFACE_WEB,
65
- assertMenuRenderable,
66
- menuRefusals,
67
- normalizeMenuModel,
68
- parseMenuModel,
69
- } from '@gjsify/adwaita-core';
70
- import type { AdwMenuActions, AdwMenuInput, AdwMenuModel } from '@gjsify/adwaita-core';
64
+ import type { AdwMenuActions, AdwMenuModel } from '@gjsify/adwaita-core';
65
+
66
+ import { MenuSlot } from './menu-slot.js';
71
67
 
72
68
  // Both definitions have to be there before this module's own can build them in
73
69
  // `connectedCallback`; a combined `import { GtkPopoverMenu }` would be elided, because the
@@ -82,7 +78,14 @@ import { WidgetElement } from '../widget-element.js';
82
78
  const LONG_PRESS_MS = 500;
83
79
 
84
80
  export class GtkPopoverBin extends WidgetElement {
85
- private _model: AdwMenuModel = [];
81
+ /**
82
+ * The model's door and `items-changed` subscription (ADR 0128), with NOTHING to do
83
+ * when the menu it holds mutates: this bin draws no rows. The `<gtk-popover-menu>` it
84
+ * built holds the SAME model and watches it through a slot of its own, so the rows
85
+ * follow one level down — and GTK sends no `notify::menu-model` for a mutation either
86
+ * (measured, `MENU_MODEL_NOTIFY_VECTORS`).
87
+ */
88
+ private readonly _slot = new MenuSlot(() => undefined);
86
89
  private _popover: GtkPopoverMenu | null = null;
87
90
  private _actions: AdwMenuActions | null = null;
88
91
  private _initialized = false;
@@ -92,9 +95,9 @@ export class GtkPopoverBin extends WidgetElement {
92
95
  return ['menu-model', 'handle-input'];
93
96
  }
94
97
 
95
- /** The menu model, normalised (ADR 0042). */
96
- get menuModel(): AdwMenuModel {
97
- return this._model;
98
+ /** The menu this bin pops up, by identity, or `null` (ADR 0128). */
99
+ get menuModel(): AdwMenuModel | null {
100
+ return this._slot.model;
98
101
  }
99
102
 
100
103
  /**
@@ -102,9 +105,8 @@ export class GtkPopoverBin extends WidgetElement {
102
105
  * setters clear each other (gtkpopoverbin.c:474-500, :543-551). A model REPLACES the
103
106
  * popover; a popover DROPS the model, and both notify for both properties.
104
107
  */
105
- set menuModel(value: AdwMenuInput | AdwMenuModel) {
106
- this._model = normalizeMenuModel(value as AdwMenuInput);
107
- assertMenuRenderable(this._model, ADW_MENU_SURFACE_WEB);
108
+ set menuModel(value: AdwMenuModel | string | null) {
109
+ this._slot.write(value);
108
110
  this._adoptPopover(this._buildPopover());
109
111
  this._notifyModel();
110
112
  }
@@ -123,7 +125,7 @@ export class GtkPopoverBin extends WidgetElement {
123
125
  }
124
126
 
125
127
  set popoverElement(value: GtkPopoverMenu | null) {
126
- this._model = [];
128
+ this._slot.write(null);
127
129
  this._adoptPopover(value);
128
130
  this._notifyModel();
129
131
  }
@@ -157,8 +159,8 @@ export class GtkPopoverBin extends WidgetElement {
157
159
  this.addEventListener('contextmenu', (event) => this._onContextMenu(event));
158
160
  this.addEventListener('keydown', (event) => this._onKeyDown(event));
159
161
  this._wireGestures();
160
- if (this._model.length === 0) this._model = this._parseMenuAttr();
161
- if (this._model.length > 0) this._adoptPopover(this._buildPopover());
162
+ if (this._slot.model === null) this._slot.writeMarkup(this.getAttribute('menu-model'));
163
+ this._adoptPopover(this._buildPopover());
162
164
  }
163
165
 
164
166
  disconnectedCallback() {
@@ -176,8 +178,8 @@ export class GtkPopoverBin extends WidgetElement {
176
178
  return;
177
179
  }
178
180
  if (name === 'menu-model') {
179
- this._model = this._parseMenuAttr();
180
- this._adoptPopover(this._model.length > 0 ? this._buildPopover() : null);
181
+ this._slot.writeMarkup(this.getAttribute('menu-model'));
182
+ this._adoptPopover(this._buildPopover());
181
183
  this._notifyModel();
182
184
  }
183
185
  }
@@ -265,14 +267,20 @@ export class GtkPopoverBin extends WidgetElement {
265
267
  this.popup();
266
268
  }
267
269
 
270
+ /**
271
+ * The popover follows the PRESENCE of a model, not its length: an empty non-null
272
+ * `Gio.Menu` at `menu-model` still builds a `GtkPopoverMenu` in GTK and still
273
+ * dissociates an application-set popover (measured, `MENU_MODEL_DOOR_VECTORS`), and
274
+ * `null` is the one spelling of "no menu".
275
+ */
268
276
  private _buildPopover(): GtkPopoverMenu | null {
269
- if (this._model.length === 0) return null;
277
+ if (this._slot.model === null) return null;
270
278
  const popover = document.createElement('gtk-popover-menu') as GtkPopoverMenu;
271
279
  // `gtk_popover_set_position (popover, GTK_POS_BOTTOM)` in the same function
272
280
  // (gtkpopoverbin.c:493), on a popover `gtk_popover_menu_new_from_model` made.
273
281
  popover.setAttribute('position', 'bottom');
274
282
  popover.setAttribute('align', 'start');
275
- popover.menuModel = this._model;
283
+ popover.menuModel = this._slot.model;
276
284
  popover.actions = this._actions;
277
285
  popover.anchor = this;
278
286
  return popover;
@@ -313,24 +321,10 @@ export class GtkPopoverBin extends WidgetElement {
313
321
  this.dispatchEvent(
314
322
  new CustomEvent('notify::menu-model', {
315
323
  bubbles: true,
316
- detail: { menuModel: this._model, popover: this._popover },
324
+ detail: { menuModel: this._slot.model, popover: this._popover },
317
325
  }),
318
326
  );
319
327
  }
320
-
321
- /**
322
- * The `menu-model` ATTRIBUTE through the core parser, refusing a `custom` item the way
323
- * every menu-bearing surface here does: markup is parsed by the browser, so a throw from
324
- * `connectedCallback` would reach nobody and the menu would be silently empty.
325
- */
326
- private _parseMenuAttr(): AdwMenuModel {
327
- const model = normalizeMenuModel(parseMenuModel(this.getAttribute('menu-model')));
328
- const refusals = menuRefusals(model, ADW_MENU_SURFACE_WEB);
329
- if (refusals.length === 0) return model;
330
- console.error(`${ADW_MENU_SURFACE_WEB.name} cannot render this menu, so it has none:`);
331
- for (const refusal of refusals) console.error(` [${refusal.path.join('.')}] ${refusal.message}`);
332
- return [];
333
- }
334
328
  }
335
329
 
336
330
  customElements.define('gtk-popover-bin', GtkPopoverBin);
@@ -37,7 +37,8 @@
37
37
  // menu-model — the portable menu model as JSON (ADR 0042), whose TOPLEVEL entries must
38
38
  // all be submenus.
39
39
  // Properties:
40
- // menuModel — the model, normalised (get/set; a `custom` item throws here).
40
+ // menuModel — the real `Gio.MenuModel`, or `null` (get/set; ADR 0128 — a `custom` item
41
+ // throws here).
41
42
  // actions — what the action group publishes about the actions the menus name.
42
43
  // activeItem — the focused/selected item's button, or `null`. GTK's `active_item`.
43
44
  // Methods:
@@ -57,16 +58,10 @@
57
58
  // Copyright (c) GNOME contributors (GTK, libadwaita). LGPLv2.1+
58
59
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
59
60
 
60
- import {
61
- ADW_MENU_SURFACE_WEB,
62
- assertMenuRenderable,
63
- menuItemAt,
64
- menuLabelText,
65
- menuRefusals,
66
- normalizeMenuModel,
67
- parseMenuModel,
68
- } from '@gjsify/adwaita-core';
69
- import type { AdwMenuActions, AdwMenuInput, AdwMenuModel, AdwMenuPath, AdwMenuSubmenu } from '@gjsify/adwaita-core';
61
+ import { buildMenu, menuItemAt, menuLabelText } from '@gjsify/adwaita-core';
62
+ import type { AdwMenuActions, AdwMenuModel, AdwMenuPath, AdwMenuSubmenu } from '@gjsify/adwaita-core';
63
+
64
+ import { MenuSlot } from './menu-slot.js';
70
65
 
71
66
  // The definition has to be there before this module's own can build one in
72
67
  // `connectedCallback`; a combined `import { GtkPopoverMenu }` would be elided, because the
@@ -88,7 +83,8 @@ interface MenuBarItem {
88
83
  }
89
84
 
90
85
  export class GtkPopoverMenuBar extends WidgetElement {
91
- private _model: AdwMenuModel = [];
86
+ /** The model, its tree snapshot and its `items-changed` subscription (ADR 0128). */
87
+ private readonly _slot = new MenuSlot(() => this._menuChanged());
92
88
  private _actions: AdwMenuActions | null = null;
93
89
  private _items: MenuBarItem[] = [];
94
90
  private _active: MenuBarItem | null = null;
@@ -98,14 +94,29 @@ export class GtkPopoverMenuBar extends WidgetElement {
98
94
  return ['menu-model'];
99
95
  }
100
96
 
101
- /** The menu model, normalised (ADR 0042). */
102
- get menuModel(): AdwMenuModel {
103
- return this._model;
97
+ /** The menu this bar draws, by identity, or `null` (ADR 0128). */
98
+ get menuModel(): AdwMenuModel | null {
99
+ return this._slot.model;
100
+ }
101
+
102
+ set menuModel(value: AdwMenuModel | string | null) {
103
+ this._slot.write(value);
104
+ this._menuChanged();
104
105
  }
105
106
 
106
- set menuModel(value: AdwMenuInput) {
107
- this._model = normalizeMenuModel(value);
108
- assertMenuRenderable(this._model, ADW_MENU_SURFACE_WEB);
107
+ /**
108
+ * The one place a changed menu is answered — from either door AND from a mutation of
109
+ * the menu the bar is holding, which is why the `console.warn` below is here rather
110
+ * than beside the attribute parse it used to live in: a toplevel entry that is not a
111
+ * submenu can arrive by `append_item` just as easily as by markup.
112
+ */
113
+ private _menuChanged(): void {
114
+ for (const [index, node] of this._slot.tree.entries()) {
115
+ if (node.kind === 'submenu') continue;
116
+ // A WARNING and not an error, and the distinction is GTK's: `g_warning` is
117
+ // recoverable, and the submenus beside the offending entry still work.
118
+ console.warn(`Don't know how to handle this item: top-level entry ${index} is a ${node.kind}.`);
119
+ }
109
120
  if (this._initialized) this._rebuild();
110
121
  }
111
122
 
@@ -136,14 +147,14 @@ export class GtkPopoverMenuBar extends WidgetElement {
136
147
  this.addEventListener('pointerleave', () => {
137
148
  if (this._active !== null && !this._active.popover.open) this._setActive(null, false);
138
149
  });
139
- if (this._model.length === 0) this._model = this._parseMenuAttr();
140
- this._rebuild();
150
+ if (this._slot.model === null) this._slot.writeMarkup(this.getAttribute('menu-model'));
151
+ this._menuChanged();
141
152
  }
142
153
 
143
154
  attributeChangedCallback(name?: string) {
144
155
  if (!this._initialized || name !== 'menu-model') return;
145
- this._model = this._parseMenuAttr();
146
- this._rebuild();
156
+ this._slot.writeMarkup(this.getAttribute('menu-model'));
157
+ this._menuChanged();
147
158
  }
148
159
 
149
160
  /** `gtk_popover_menu_bar_select_first` — activate and OPEN the first item. */
@@ -213,7 +224,7 @@ export class GtkPopoverMenuBar extends WidgetElement {
213
224
  this._items = [];
214
225
  this._active = null;
215
226
  const built: MenuBarItem[] = [];
216
- this._model.forEach((node, index) => {
227
+ this._slot.tree.forEach((node, index) => {
217
228
  if (node.kind !== 'submenu') return;
218
229
  built.push(this._buildItem(node, [index]));
219
230
  });
@@ -237,8 +248,8 @@ export class GtkPopoverMenuBar extends WidgetElement {
237
248
  /**
238
249
  * One `menubar > item > popover` (the node tree in gtkpopovermenubar.c:38-46).
239
250
  *
240
- * A toplevel node that is not a submenu is refused by `_parseMenuAttr` with GTK's own
241
- * wording rather than drawn: `tracker_insert` builds nothing for it
251
+ * A toplevel node that is not a submenu is WARNED about by {@link _menuChanged} with
252
+ * GTK's own wording rather than drawn: `tracker_insert` builds nothing for it
242
253
  * (gtkpopovermenubar.c:466), and a row that opens nothing is the same lie in another
243
254
  * shape.
244
255
  */
@@ -269,9 +280,10 @@ export class GtkPopoverMenuBar extends WidgetElement {
269
280
  // (gtkpopovermenubar.c:435-436).
270
281
  popover.setAttribute('position', 'bottom');
271
282
  popover.setAttribute('align', 'start');
272
- // The submenu's OWN items, handed back as they are: `normalizeMenuModel` is
273
- // idempotent and explicitly takes an already-normalised node (ADR 0042).
274
- popover.menuModel = node.items;
283
+ // The submenu's OWN items, as a menu OF THEIR OWN: since ADR 0128 the popover takes
284
+ // a real `Gio.MenuModel`, and `buildMenu` is how a tree becomes one. Handing over
285
+ // the bar's own model instead would give the popover the whole bar.
286
+ popover.menuModel = buildMenu(node.items);
275
287
  popover.actions = this._actions;
276
288
  popover.anchor = button;
277
289
  // `gtk_accessible_update_relation (…, LABELLED_BY, item->label, CONTROLS,
@@ -324,7 +336,7 @@ export class GtkPopoverMenuBar extends WidgetElement {
324
336
  * row is not the row that opened a submenu (gtkpopovermenu.c:257-259).
325
337
  */
326
338
  private _activate(item: MenuBarItem, path: AdwMenuPath): void {
327
- const target = menuItemAt(this._model, path);
339
+ const target = menuItemAt(this._slot.tree, path);
328
340
  item.popover.popdown();
329
341
  item.button.focus();
330
342
  if (target === null) return;
@@ -335,23 +347,6 @@ export class GtkPopoverMenuBar extends WidgetElement {
335
347
  }),
336
348
  );
337
349
  }
338
-
339
- private _parseMenuAttr(): AdwMenuModel {
340
- const model = normalizeMenuModel(parseMenuModel(this.getAttribute('menu-model')));
341
- const refusals = menuRefusals(model, ADW_MENU_SURFACE_WEB);
342
- if (refusals.length > 0) {
343
- console.error(`${ADW_MENU_SURFACE_WEB.name} cannot render this menu, so it has none:`);
344
- for (const refusal of refusals) console.error(` [${refusal.path.join('.')}] ${refusal.message}`);
345
- return [];
346
- }
347
- for (const [index, node] of model.entries()) {
348
- if (node.kind === 'submenu') continue;
349
- // A WARNING and not an error, and the distinction is GTK's: `g_warning` is
350
- // recoverable, and the submenus beside the offending entry still work.
351
- console.warn(`Don't know how to handle this item: top-level entry ${index} is a ${node.kind}.`);
352
- }
353
- return model;
354
- }
355
350
  }
356
351
 
357
352
  customElements.define('gtk-popover-menu-bar', GtkPopoverMenuBar);