@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
@@ -47,7 +47,7 @@
47
47
  // Copyright (c) GNOME contributors (GTK, libadwaita). LGPLv2.1+.
48
48
 
49
49
  import { flattenMenu, menuLabelText, menuNodeAt, resolveMenuItemState } from '@gjsify/adwaita-core';
50
- import type { AdwMenuActions, AdwMenuItem, AdwMenuModel, AdwMenuPath } from '@gjsify/adwaita-core';
50
+ import type { AdwMenuActions, AdwMenuItem, AdwMenuPath, AdwMenuTree } from '@gjsify/adwaita-core';
51
51
 
52
52
  import { createGtkImage } from './gtk-image.js';
53
53
  import type { GtkPopover } from './gtk-popover.js';
@@ -71,7 +71,7 @@ const BACK_LABEL = 'Back';
71
71
  * reopens three levels deep is a menu the reader cannot get out of.
72
72
  */
73
73
  export class PopoverMenuView {
74
- private _model: AdwMenuModel = [];
74
+ private _tree: AdwMenuTree = [];
75
75
  private _actions: AdwMenuActions | null = null;
76
76
  private _title = '';
77
77
  /** The submenu whose items are showing; `[]` is the root page. */
@@ -80,7 +80,7 @@ export class PopoverMenuView {
80
80
  private _openers = new Set<HTMLElement>();
81
81
  /** The row that LEAVES a page — what ArrowLeft acts on. */
82
82
  private _backRow: HTMLElement | null = null;
83
- /** The canonical text of the model showing, so an unchanged re-render keeps the page. */
83
+ /** The canonical text of the tree showing, so an unchanged re-render keeps the page. */
84
84
  private _key = '[]';
85
85
  private _rows: HTMLButtonElement[] = [];
86
86
 
@@ -181,24 +181,34 @@ export class PopoverMenuView {
181
181
  }
182
182
 
183
183
  /**
184
- * Named for the value it takes, and NOT `setModel` — the portable LIST model gave
184
+ * Named for the value it takes, which since ADR 0128 is a TREE and not a model: this
185
+ * view never holds a `Gio.MenuModel`, it walks the snapshot its element took, and a
186
+ * `setMenuModel` here would name a type it has no way to read. The element is the one
187
+ * place that owns the model.
188
+ *
189
+ * It was `setMenuModel` for the reason the rename keeps: the portable LIST model gave
185
190
  * `ComboState` a `setModel` (ADR 0046), and two methods called `setModel` over two
186
191
  * unrelated values on one surface is the collision the vocabulary work exists to
187
- * remove. It also un-blinds `check-adwaita-collection-reactivity`, which resolves a
188
- * collection call by the setter's name.
192
+ * remove.
189
193
  */
190
- setMenuModel(model: AdwMenuModel): void {
191
- // A CHANGED model invalidates the open page: the submenu it named may not exist
194
+ setMenuTree(tree: AdwMenuTree): void {
195
+ // A CHANGED tree invalidates the open page: the submenu it named may not exist
192
196
  // any more, and rendering a page that is gone leaves an empty popover with a
193
197
  // back row and no way to see what happened. An UNCHANGED one must not — every
194
198
  // element re-render calls this, and resetting unconditionally shut the submenu
195
199
  // the reader had just opened.
196
- const key = JSON.stringify(model);
200
+ //
201
+ // COMPARED BY VALUE, not by identity, which is the opposite of the guard
202
+ // `SplitButtonState.setMenuModel` has: the element's AUTHORED menu keeps its
203
+ // identity while its content is replaced (ADR 0128 § 5), so an identity guard
204
+ // here would never see an edit to a `menu-model="[…]"` attribute and would keep
205
+ // showing a page that is gone.
206
+ const key = JSON.stringify(tree);
197
207
  if (key !== this._key) {
198
208
  this._key = key;
199
209
  this._page = [];
200
210
  }
201
- this._model = model;
211
+ this._tree = tree;
202
212
  }
203
213
 
204
214
  setActions(actions: AdwMenuActions | null): void {
@@ -215,10 +225,10 @@ export class PopoverMenuView {
215
225
  }
216
226
 
217
227
  /** The items of the page currently showing. */
218
- private currentItems(): AdwMenuModel {
219
- if (this._page.length === 0) return this._model;
220
- const node = menuNodeAt(this._model, this._page);
221
- return node !== null && node.kind !== 'item' ? node.items : this._model;
228
+ private currentItems(): AdwMenuTree {
229
+ if (this._page.length === 0) return this._tree;
230
+ const node = menuNodeAt(this._tree, this._page);
231
+ return node !== null && node.kind !== 'item' ? node.items : this._tree;
222
232
  }
223
233
 
224
234
  /** Rebuild the surface's children from the model. */
@@ -228,7 +238,7 @@ export class PopoverMenuView {
228
238
  this._openers = new Set();
229
239
  this._backRow = null;
230
240
 
231
- const open = menuNodeAt(this._model, this._page);
241
+ const open = menuNodeAt(this._tree, this._page);
232
242
  if (this._page.length > 0 && open !== null && open.kind === 'submenu') {
233
243
  this.surface.appendChild(this.backRow(open.label));
234
244
  } else if (this._title.length > 0) {
@@ -263,7 +273,7 @@ export class PopoverMenuView {
263
273
  /** The label of the section at `path`, or `null` when it is not a labelled section. */
264
274
  private sectionHeading(path: AdwMenuPath): string | null {
265
275
  if (path.length <= this._page.length) return null;
266
- const parent = menuNodeAt(this._model, path);
276
+ const parent = menuNodeAt(this._tree, path);
267
277
  return parent !== null && parent.kind === 'section' && parent.label !== undefined ? parent.label : null;
268
278
  }
269
279
 
@@ -1,46 +1,85 @@
1
- // `Gio.Menu` as an authoring door — held against the value it has to produce.
1
+ // `Gio.Menu` as an authoring door, and the `menu-model` PROPERTY the widgets expose.
2
2
  //
3
- // THE CLAIM UNDER TEST is not "the class has methods": it is that a menu BUILT the GJS way
4
- // and the same menu WRITTEN as an array reduce to one model, so a `menuModel` property
5
- // cannot tell them apart. Every case below therefore asserts against
6
- // `normalizeMenuModel(<the array a reader would have written>)` rather than against a
3
+ // THE CLAIM UNDER TEST in the first half is not "the class has methods": it is that a menu
4
+ // BUILT the GJS way and the same menu WRITTEN as the portable tree reduce to one snapshot,
5
+ // so a `menuModel` property cannot tell them apart. Every case there asserts against
6
+ // `normalizeMenuModel(<the tree a reader would have written>)` rather than against a
7
7
  // hand-typed expectation — a hand-typed one would agree with whatever this file produces.
8
8
  //
9
- // The class itself lives in `@gjsify/adwaita-core`; what this file drives is the port's
10
- // own `Gio` DOOR onto it, so a barrel that stops resolving fails here rather than in a
11
- // consumer.
9
+ // THE SECOND HALF IS WHAT THE CORE SPEC CANNOT DRIVE. ADR 0128 measured two of its tables on
10
+ // WIDGETS and not on the model: `MENU_MODEL_DOOR_VECTORS` (what a `menu-model` write accepts
11
+ // and the words it refuses with) and `MENU_MODEL_NOTIFY_VECTORS` (the identity guard
12
+ // `Adw.SplitButton` has and `Gtk.MenuButton` does not). Both run against BOTH widgets here,
13
+ // because a guard measured on one and applied to the other is the exact mistake the table
14
+ // exists to catch: GJS notifies once for a repeated write on the split button and twice on
15
+ // the menu button, and the asymmetry is upstream's, not a port decision.
16
+ //
17
+ // The classes themselves live in `@gjsify/adwaita-core`; what the first half drives is the
18
+ // port's own `Gio` DOOR onto them, so a barrel that stops resolving fails here rather than
19
+ // in a consumer.
12
20
  //
13
21
  // Compared as JSON rather than with `toEqual`: `@gjsify/unit`'s `toEqual` is `==`, so two
14
22
  // different objects pass it whatever they hold.
15
23
 
16
24
  import { describe, expect, it } from '@gjsify/unit';
17
- import { normalizeMenuModel } from '@gjsify/adwaita-core';
25
+ import { ADW_MENU_SURFACE_WEB, buildMenu, menuTree, normalizeMenuModel } from '@gjsify/adwaita-core';
26
+ import { MENU_MODEL_DOOR_VECTORS, MENU_MODEL_NOTIFY_VECTORS } from '@gjsify/adwaita-core/conformance';
27
+ import type { MenuModelHolder, MenuModelWrite } from '@gjsify/adwaita-core/conformance';
28
+ import type { AdwMenuModel, GioMenuModel } from '@gjsify/adwaita-core';
29
+
30
+ import { Menu, MenuItem, MenuModel } from './namespace/gio.js';
31
+ import { StringList } from './namespace/gtk.js';
32
+
33
+ /** The snapshot a renderer reads out of a model — what every walker takes. */
34
+ const treeOf = (model: GioMenuModel) => JSON.stringify(menuTree(model));
35
+
36
+ /** The same menu written the portable way, which is what the model has to reduce to. */
37
+ const written = (tree: readonly unknown[]) => JSON.stringify(normalizeMenuModel(tree as never));
38
+
39
+ /** A `menu-model` holder, mounted: both widgets take the property and both are measured. */
40
+ interface MenuHolderElement extends HTMLElement {
41
+ menuModel: AdwMenuModel | string | null;
42
+ }
43
+
44
+ const TAGS: Record<MenuModelHolder, string> = {
45
+ 'Adw.SplitButton': 'adw-split-button',
46
+ 'Gtk.MenuButton': 'gtk-menu-button',
47
+ };
18
48
 
19
- import { Menu, MenuItem } from './namespace/gio.js';
49
+ function mount(widget: MenuModelHolder): MenuHolderElement {
50
+ const el = document.createElement(TAGS[widget]) as MenuHolderElement;
51
+ document.body.appendChild(el);
52
+ return el;
53
+ }
20
54
 
21
- /** What a `menuModel` property does with a value, on both spellings. */
22
- const model = (value: readonly unknown[]) => JSON.stringify(normalizeMenuModel(value as never));
55
+ function unmountAll(): void {
56
+ for (const tag of Object.values(TAGS)) {
57
+ for (const el of Array.from(document.querySelectorAll(tag))) el.remove();
58
+ }
59
+ }
23
60
 
24
61
  export const GioMenuTest = async () => {
25
- await describe('Gio.Menu is the array a menuModel already takes', async () => {
26
- await it('passes Array.isArray, which is the gate normalizeMenuModel opens on', () => {
27
- expect(Array.isArray(new Menu())).toBe(true);
62
+ await describe('Gio.Menu is the tree a menuModel already takes', async () => {
63
+ await it('IS a Gio.MenuModel, which is what the property is declared over', () => {
64
+ // ADR 0128: the door is `value_type=GMenuModel`, so this `instanceof` is the
65
+ // whole reason a menu can be assigned at all.
66
+ expect(new Menu() instanceof MenuModel).toBe(true);
28
67
  });
29
68
 
30
69
  await it('starts empty, as g_menu_new() does', () => {
31
- expect(new Menu().length).toBe(0);
70
+ expect(new Menu().get_n_items()).toBe(0);
32
71
  });
33
72
  });
34
73
 
35
74
  await describe('Gio.Menu.append', async () => {
36
- await it('builds the gallery menu the array spelling builds', () => {
75
+ await it('builds the gallery menu the portable spelling builds', () => {
37
76
  const menu = new Menu();
38
77
  menu.append('Save as…', 'app.save-as');
39
78
  menu.append('Export', 'app.export');
40
79
  menu.append('Print', 'app.print');
41
80
 
42
- expect(model(menu)).toBe(
43
- model([
81
+ expect(treeOf(menu)).toBe(
82
+ written([
44
83
  { label: 'Save as…', action: 'app.save-as' },
45
84
  { label: 'Export', action: 'app.export' },
46
85
  { label: 'Print', action: 'app.print' },
@@ -52,7 +91,7 @@ export const GioMenuTest = async () => {
52
91
  const menu = new Menu();
53
92
  menu.append('Export');
54
93
 
55
- expect(model(menu)).toBe(model(['Export']));
94
+ expect(treeOf(menu)).toBe(written(['Export']));
56
95
  });
57
96
  });
58
97
 
@@ -61,7 +100,7 @@ export const GioMenuTest = async () => {
61
100
  const menu = new Menu();
62
101
  menu.append_item(new MenuItem('Save as…', 'app.save-as'));
63
102
 
64
- expect(model(menu)).toBe(model([{ label: 'Save as…', action: 'app.save-as' }]));
103
+ expect(treeOf(menu)).toBe(written([{ label: 'Save as…', action: 'app.save-as' }]));
65
104
  });
66
105
 
67
106
  await it('sets both halves after construction', () => {
@@ -69,19 +108,19 @@ export const GioMenuTest = async () => {
69
108
  item.set_label('Print');
70
109
  item.set_detailed_action('app.print');
71
110
 
72
- expect(model([item])).toBe(model([{ label: 'Print', action: 'app.print' }]));
111
+ const menu = new Menu();
112
+ menu.append_item(item);
113
+ expect(treeOf(menu)).toBe(written([{ label: 'Print', action: 'app.print' }]));
73
114
  });
74
115
 
75
116
  await it('REMOVES the attribute on null rather than carrying an undefined one', () => {
76
117
  const item = new MenuItem('Print', 'app.print');
77
118
  item.set_detailed_action(null);
78
119
 
79
- expect(Object.hasOwn(item, 'action')).toBe(false);
80
- expect(model([item])).toBe(model(['Print']));
81
- });
82
-
83
- await it('drops an item carrying neither half — g_menu_item_new(NULL, NULL)', () => {
84
- expect(model([new MenuItem()])).toBe(model([]));
120
+ const menu = new Menu();
121
+ menu.append_item(item);
122
+ expect(menu.get_item_attribute_value(0, 'action', null)).toBeNull();
123
+ expect(treeOf(menu)).toBe(written(['Print']));
85
124
  });
86
125
  });
87
126
 
@@ -93,21 +132,21 @@ export const GioMenuTest = async () => {
93
132
  const menu = new Menu();
94
133
  menu.append_section(null, section);
95
134
 
96
- expect(model(menu)).toBe(model([{ section: [{ label: 'Save as…', action: 'app.save-as' }] }]));
135
+ expect(treeOf(menu)).toBe(written([{ section: [{ label: 'Save as…', action: 'app.save-as' }] }]));
97
136
  });
98
137
 
99
138
  await it('carries the heading when C would have one', () => {
100
139
  const menu = new Menu();
101
- menu.append_section('Document', ['Export']);
140
+ menu.append_section('Document', buildMenu(['Export']));
102
141
 
103
- expect(model(menu)).toBe(model([{ label: 'Document', section: ['Export'] }]));
142
+ expect(treeOf(menu)).toBe(written([{ label: 'Document', section: ['Export'] }]));
104
143
  });
105
144
 
106
145
  await it('writes NO label attribute when there is none', () => {
107
146
  const menu = new Menu();
108
- menu.append_section(null, ['Export']);
147
+ menu.append_section(null, buildMenu(['Export']));
109
148
 
110
- expect(Object.hasOwn(menu[0] as object, 'label')).toBe(false);
149
+ expect(menu.get_item_attribute_value(0, 'label', null)).toBeNull();
111
150
  });
112
151
  });
113
152
 
@@ -119,7 +158,107 @@ export const GioMenuTest = async () => {
119
158
  const menu = new Menu();
120
159
  menu.append_submenu('More', submenu);
121
160
 
122
- expect(model(menu)).toBe(model([{ label: 'More', submenu: [{ label: 'Print', action: 'app.print' }] }]));
161
+ expect(treeOf(menu)).toBe(written([{ label: 'More', submenu: [{ label: 'Print', action: 'app.print' }] }]));
123
162
  });
124
163
  });
164
+
165
+ await describe('the menu-model door (ADR 0128 conformance vectors)', async () => {
166
+ for (const widget of Object.keys(TAGS) as MenuModelHolder[]) {
167
+ for (const vector of MENU_MODEL_DOOR_VECTORS) {
168
+ await it(`${widget} ${vector.write}: ${vector.rule}`, () => {
169
+ const el = mount(widget);
170
+ const before = el.menuModel;
171
+
172
+ // The six writes the table names, built here so the refused ones are
173
+ // the values GJS was measured refusing, not stand-ins for them.
174
+ const value: unknown = {
175
+ menu: () => buildMenu([{ kind: 'item', label: 'Save', action: 'app.save' }]),
176
+ null: () => null,
177
+ 'json-string': () => '[{"label":"Save","action":"app.save"}]',
178
+ array: () => [{ label: 'Save' }],
179
+ 'plain-object': () => ({ label: 'Save' }),
180
+ // `Gtk.StringList` is the subset's one registered GObject that is not
181
+ // a menu model, so it reaches the TYPE refusal an array never gets to.
182
+ 'wrong-gobject': () => new StringList({ strings: ['Save'] }),
183
+ }[vector.write]();
184
+
185
+ // `in` rather than `??`: a `null` under the key is MEANINGFUL — it says
186
+ // this surface accepts a write the shared row refuses. No row overrides
187
+ // THIS surface today, and reading the field anyway is the point: the
188
+ // NativeScript driver reads the same one, so a second row added there
189
+ // cannot quietly mean nothing here.
190
+ const override = vector.bySurface !== undefined && ADW_MENU_SURFACE_WEB.name in vector.bySurface;
191
+ const expected = override
192
+ ? (vector.bySurface as Record<string, string | null>)[ADW_MENU_SURFACE_WEB.name]
193
+ : vector.error;
194
+
195
+ if (expected === null) {
196
+ el.menuModel = value as AdwMenuModel | string | null;
197
+ if (vector.write === 'null') expect(el.menuModel).toBeNull();
198
+ // A real model is held BY IDENTITY; a JSON string becomes the
199
+ // surface's own authored model, which is a model all the same.
200
+ else if (vector.write === 'menu') expect(el.menuModel).toBe(value);
201
+ else expect(el.menuModel).not.toBeNull();
202
+ unmountAll();
203
+ return;
204
+ }
205
+
206
+ let message: string | null = null;
207
+ try {
208
+ el.menuModel = value as AdwMenuModel | string | null;
209
+ } catch (error) {
210
+ message = (error as Error).message;
211
+ }
212
+ // A shared row is matched WHOLE; a `bySurface` one is a fragment, because
213
+ // the full refusal names the widget class and that belongs to the caller.
214
+ if (override) expect(message).toContain(expected);
215
+ else expect(message).toBe(expected);
216
+ // A refusal leaves the widget holding what it held — the write never
217
+ // half-landed, which is what makes the throw safe to catch.
218
+ expect(el.menuModel).toBe(before);
219
+ unmountAll();
220
+ });
221
+ }
222
+ }
223
+ });
224
+
225
+ await describe('notify::menu-model and the identity guard (ADR 0128 conformance vectors)', async () => {
226
+ for (const vector of MENU_MODEL_NOTIFY_VECTORS) {
227
+ const label = `${vector.widget} [${vector.writes.map((w) => w ?? 'null').join(', ')}]`;
228
+ await it(`${label}: ${vector.rule}`, () => {
229
+ const el = mount(vector.widget);
230
+ // TWO DISTINCT models with EQUAL content, which is what separates an
231
+ // identity guard from a value one: a serialisation-keyed widget would
232
+ // report no change for the A→B write.
233
+ const models: Record<'A' | 'B', GioMenuModel> = {
234
+ A: buildMenu([{ kind: 'item', label: 'Save', action: 'app.save' }]),
235
+ B: buildMenu([{ kind: 'item', label: 'Save', action: 'app.save' }]),
236
+ };
237
+
238
+ let notifies = 0;
239
+ el.addEventListener('notify::menu-model', () => notifies++);
240
+
241
+ const counted: number[] = [];
242
+ const write = (name: MenuModelWrite) => {
243
+ notifies = 0;
244
+ el.menuModel = name === null ? null : models[name];
245
+ counted.push(notifies);
246
+ };
247
+ for (const name of vector.writes) write(name);
248
+
249
+ if (vector.mutateHeldModel === true) {
250
+ // The last step is a MUTATION of the model the widget already holds.
251
+ // It is not a property change: the content arrives over
252
+ // `items-changed`, which is why a widget keyed on a serialisation of
253
+ // the model reports the wrong thing in both directions.
254
+ notifies = 0;
255
+ (el.menuModel as unknown as { append(label: string): void }).append('Export');
256
+ counted.push(notifies);
257
+ }
258
+
259
+ expect(counted).toStrictEqual([...vector.notifies]);
260
+ unmountAll();
261
+ });
262
+ }
263
+ });
125
264
  };
@@ -9,6 +9,7 @@
9
9
  import { describe, expect, it } from '@gjsify/unit';
10
10
 
11
11
  import { VIEW_STACK_ICON_NAME_VECTORS } from '@gjsify/adwaita-core/conformance';
12
+ import { buildMenu } from '@gjsify/adwaita-core';
12
13
 
13
14
  import type { GtkImage } from './elements/gtk-image.js';
14
15
  import type { GtkMenuButton } from './elements/gtk-menu-button.js';
@@ -124,11 +125,11 @@ export const GtkImageTest = async () => {
124
125
 
125
126
  await it('<gtk-menu-button> menu entries guard their icons too', () => {
126
127
  const { el, host } = mount<GtkMenuButton>('gtk-menu-button');
127
- el.menuModel = [
128
+ el.menuModel = buildMenu([
128
129
  { label: 'Bad', icon: 'a b' },
129
130
  { label: 'Good', icon: 'view-refresh-symbolic' },
130
131
  { label: 'None' },
131
- ];
132
+ ]);
132
133
  const items = [...el.querySelectorAll('.adw-menu-button-item')];
133
134
  expect(items.length).toBe(3);
134
135
  // An entry that ASKED for an icon keeps its slot even when the name is
@@ -8,11 +8,11 @@
8
8
 
9
9
  import { describe, expect, it } from '@gjsify/unit';
10
10
  import { MENU_ITEM_STATE_VECTORS, MENU_PARSE_VECTORS, MENU_REFUSAL_VECTORS } from '@gjsify/adwaita-core/conformance';
11
- import { flattenMenu } from '@gjsify/adwaita-core';
12
- import type { AdwMenuInput } from '@gjsify/adwaita-core';
11
+ import { buildMenu, flattenMenu } from '@gjsify/adwaita-core';
12
+ import type { AdwMenuModel } from '@gjsify/adwaita-core';
13
13
 
14
14
  interface MenuButtonElement extends HTMLElement {
15
- menuModel: AdwMenuInput;
15
+ menuModel: AdwMenuModel | string | null;
16
16
  actions: Record<string, { enabled?: boolean; state?: string }> | null;
17
17
  }
18
18
 
@@ -33,12 +33,12 @@ function unmountAll(): void {
33
33
 
34
34
  export const GtkMenuButtonTest = async () => {
35
35
  await describe('<gtk-menu-button> menu attribute', async () => {
36
- for (const { json, model, rule } of MENU_PARSE_VECTORS) {
36
+ for (const { json, tree, rule } of MENU_PARSE_VECTORS) {
37
37
  await it(`${rule}`, () => {
38
38
  const { labels } = mountWithMenu(json ?? '');
39
39
  // The rendered rows are what `flattenMenu` says: a section is inlined,
40
40
  // a submenu is ONE row, and both keep their own label.
41
- expect(labels).toStrictEqual(flattenMenu(model).map((row) => row.node.label));
41
+ expect(labels).toStrictEqual(flattenMenu(tree).map((row) => row.node.label));
42
42
  unmountAll();
43
43
  });
44
44
  }
@@ -71,14 +71,14 @@ export const GtkMenuButtonTest = async () => {
71
71
  const el = document.createElement('gtk-menu-button') as MenuButtonElement;
72
72
  document.body.appendChild(el);
73
73
  expect(el.querySelectorAll('.adw-popover-item').length).toBe(0);
74
- el.menuModel = ['New', 'Open'];
74
+ el.menuModel = buildMenu(['New', 'Open']);
75
75
  expect([...el.querySelectorAll('.adw-menu-button-item-label')].map((n) => n.textContent)).toStrictEqual([
76
76
  'New',
77
77
  'Open',
78
78
  ]);
79
79
  // REPLACED, not merely seeded — the failure mode is a second assignment
80
80
  // writing an expando while the first happened to work at connect.
81
- el.menuModel = [{ label: 'Only', action: 'app.only' }];
81
+ el.menuModel = buildMenu([{ label: 'Only', action: 'app.only' }]);
82
82
  expect([...el.querySelectorAll('.adw-menu-button-item-label')].map((n) => n.textContent)).toStrictEqual([
83
83
  'Only',
84
84
  ]);
@@ -100,11 +100,11 @@ export const GtkMenuButtonTest = async () => {
100
100
  await it('draws a section heading, a separator and a submenu page', () => {
101
101
  const el = document.createElement('gtk-menu-button') as MenuButtonElement;
102
102
  document.body.appendChild(el);
103
- el.menuModel = [
103
+ el.menuModel = buildMenu([
104
104
  { label: 'New' },
105
105
  { section: [{ label: 'Cut' }, { label: 'Copy' }], label: 'Edit' },
106
106
  { label: 'More', submenu: [{ label: 'Rename' }, { label: 'Duplicate' }] },
107
- ];
107
+ ]);
108
108
 
109
109
  // TWO: a section is separated from what comes before it AND from what comes
110
110
  // after, which is what `GtkPopoverMenu` draws for the same model.
@@ -137,7 +137,7 @@ export const GtkMenuButtonTest = async () => {
137
137
  const el = document.createElement('gtk-menu-button') as MenuButtonElement;
138
138
  document.body.appendChild(el);
139
139
  el.actions = { ...actions };
140
- el.menuModel = [item];
140
+ el.menuModel = buildMenu([item]);
141
141
  const row = el.querySelector('.adw-popover-item') as HTMLButtonElement;
142
142
  expect(row.hidden).toBe(!state.visible);
143
143
  expect(row.disabled).toBe(!state.sensitive);
@@ -179,15 +179,15 @@ export const GtkMenuButtonTest = async () => {
179
179
 
180
180
  await it('menu_model / get_menu_model / set_menu_model mirror menuModel', () => {
181
181
  const el = document.createElement('gtk-menu-button') as MenuButtonElement & {
182
- menu_model: AdwMenuInput;
182
+ menu_model: AdwMenuModel | null;
183
183
  get_menu_model(): unknown;
184
- set_menu_model(v: AdwMenuInput): void;
184
+ set_menu_model(v: AdwMenuModel | null): void;
185
185
  };
186
186
  document.body.appendChild(el);
187
- el.set_menu_model(['Save', 'Quit']);
187
+ el.set_menu_model(buildMenu(['Save', 'Quit']));
188
188
  expect(el.get_menu_model()).toBe(el.menuModel);
189
189
  expect(el.querySelectorAll('.adw-popover-item').length).toBe(2);
190
- el.menu_model = ['Save'];
190
+ el.menu_model = buildMenu(['Save']);
191
191
  expect(el.querySelectorAll('.adw-popover-item').length).toBe(1);
192
192
  unmountAll();
193
193
  });
@@ -200,13 +200,16 @@ export const GtkMenuButtonTest = async () => {
200
200
  unmountAll();
201
201
  });
202
202
 
203
- for (const { model, paths, rule } of MENU_REFUSAL_VECTORS) {
203
+ for (const { tree, paths, rule } of MENU_REFUSAL_VECTORS) {
204
204
  await it(`refusal — ${rule}`, () => {
205
205
  const el = document.createElement('gtk-menu-button') as MenuButtonElement;
206
206
  document.body.appendChild(el);
207
+ // `buildMenu` is how a tree becomes a model now: the property takes the
208
+ // real thing (ADR 0128), so the refusal happens on the model's snapshot.
209
+ const model = buildMenu(tree);
207
210
  if (paths.length === 0) {
208
211
  el.menuModel = model;
209
- expect(el.querySelectorAll('.adw-popover-item').length).toBe(flattenMenu(model).length);
212
+ expect(el.querySelectorAll('.adw-popover-item').length).toBe(flattenMenu(tree).length);
210
213
  } else {
211
214
  // LOUD, at the assignment: a `custom` item names an application
212
215
  // widget, and a surface that ignored it would draw a blank row.
@@ -9,10 +9,11 @@
9
9
  // · `menu.popup` is an ACTION, so the element exposes it as an event too.
10
10
 
11
11
  import { describe, expect, it } from '@gjsify/unit';
12
- import type { AdwMenuInput } from '@gjsify/adwaita-core';
12
+ import { buildMenu } from '@gjsify/adwaita-core';
13
+ import type { AdwMenuModel } from '@gjsify/adwaita-core';
13
14
 
14
15
  interface PopoverBinElement extends HTMLElement {
15
- menuModel: AdwMenuInput;
16
+ menuModel: AdwMenuModel | string | null;
16
17
  // `popoverElement`, not `popover`: `HTMLElement` already declares a `popover` STRING
17
18
  // attribute for the DOM Popover API, and an interface cannot narrow that to a widget.
18
19
  popoverElement: HTMLElement | null;
@@ -67,7 +68,7 @@ export const GtkPopoverBinTest = async () => {
67
68
  await it('setting the model REPLACES the popover', () => {
68
69
  const { el } = mount(JSON.stringify(MENU));
69
70
  const before = popoverOf(el);
70
- el.menuModel = [{ label: 'Only' }];
71
+ el.menuModel = buildMenu([{ label: 'Only' }]);
71
72
  expect(popoverOf(el)).not.toBe(before);
72
73
  expect(before?.isConnected).toBe(false);
73
74
  unmountAll();
@@ -80,7 +81,7 @@ export const GtkPopoverBinTest = async () => {
80
81
  // that is easy to get wrong by keeping them apart.
81
82
  const replacement = document.createElement('gtk-popover') as unknown as HTMLElement;
82
83
  el.popoverElement = replacement;
83
- expect(el.menuModel).toStrictEqual([]);
84
+ expect(el.menuModel).toBeNull();
84
85
  expect(el.popoverElement).toBe(replacement);
85
86
  // The old popover is unparented rather than left behind as a second surface:
86
87
  // `gtk_popover_bin_set_popover` unparents the one it displaces.
@@ -92,7 +93,7 @@ export const GtkPopoverBinTest = async () => {
92
93
  const { el } = mount();
93
94
  const seen: unknown[] = [];
94
95
  el.addEventListener('notify::menu-model', (event) => seen.push((event as CustomEvent).detail));
95
- el.menuModel = MENU;
96
+ el.menuModel = buildMenu(MENU);
96
97
  // GTK notifies `:popover` AND `:menu-model` from BOTH setters
97
98
  // (gtkpopoverbin.c:502, :586-587), so one event carrying both is the faithful
98
99
  // reading rather than two that can disagree.
@@ -5,22 +5,27 @@
5
5
 
6
6
  import { describe, expect, it } from '@gjsify/unit';
7
7
  import { MENU_LABEL_TEXT_VECTORS } from '@gjsify/adwaita-core/conformance';
8
- import type { AdwMenuInput } from '@gjsify/adwaita-core';
8
+ import { buildMenu } from '@gjsify/adwaita-core';
9
+ import type { AdwMenuModel } from '@gjsify/adwaita-core';
9
10
 
10
11
  interface MenuBarElement extends HTMLElement {
11
- menuModel: AdwMenuInput;
12
+ menuModel: AdwMenuModel | null;
12
13
  actions: Record<string, { enabled?: boolean; state?: string }> | null;
13
14
  activeItem: HTMLElement | null;
14
15
  selectFirst(): void;
15
16
  popup(): void;
16
17
  }
17
18
 
18
- /** Three submenus, the shape GTK builds one item for (`tracker_insert`, :422-468). */
19
- const MENU = [
19
+ /**
20
+ * Three submenus, the shape GTK builds one item for (`tracker_insert`, :422-468). ONE model
21
+ * for the whole file, as GTK has it: a menu is a `GMenuModel` and several widgets may hold
22
+ * the same one.
23
+ */
24
+ const MENU = buildMenu([
20
25
  { label: 'File', submenu: [{ label: 'New' }, { label: 'Open' }] },
21
26
  { label: 'Edit', submenu: [{ label: 'Cut' }] },
22
27
  { label: 'View', submenu: [{ label: 'Zoom In' }] },
23
- ];
28
+ ]);
24
29
 
25
30
  function mount(json?: string): MenuBarElement {
26
31
  const el = document.createElement('gtk-popover-menu-bar') as MenuBarElement;
@@ -299,7 +304,7 @@ export const GtkPopoverMenuBarTest = async () => {
299
304
  for (const { label, text, rule } of MENU_LABEL_TEXT_VECTORS) {
300
305
  await it(rule, () => {
301
306
  const el = mount();
302
- el.menuModel = [{ label, submenu: [{ label: 'Inner' }] }];
307
+ el.menuModel = buildMenu([{ label, submenu: [{ label: 'Inner' }] }]);
303
308
  expect(buttons(el).map((b) => b.textContent)).toStrictEqual([text]);
304
309
  unmountAll();
305
310
  });