@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
@@ -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
  });
@@ -9,11 +9,11 @@
9
9
 
10
10
  import { describe, expect, it } from '@gjsify/unit';
11
11
  import { MENU_LABEL_TEXT_VECTORS, MENU_REFUSAL_VECTORS } from '@gjsify/adwaita-core/conformance';
12
- import { flattenMenu } from '@gjsify/adwaita-core';
13
- import type { AdwMenuInput, AdwMenuModel } from '@gjsify/adwaita-core';
12
+ import { buildMenu, flattenMenu } from '@gjsify/adwaita-core';
13
+ import type { AdwMenuModel, AdwMenuTree } from '@gjsify/adwaita-core';
14
14
 
15
15
  interface PopoverMenuElement extends HTMLElement {
16
- menuModel: AdwMenuInput | AdwMenuModel;
16
+ menuModel: AdwMenuModel | string | null;
17
17
  actions: Record<string, { enabled?: boolean; state?: string }> | null;
18
18
  visibleSubmenu: string;
19
19
  // The three below are `GtkPopover`'s own — the reason this spec can say "inherits the
@@ -76,7 +76,7 @@ export const GtkPopoverMenuTest = async () => {
76
76
  await describe('<gtk-popover-menu> the page stack', async () => {
77
77
  await it('renders the model and reports the root page as `main`', () => {
78
78
  const el = mount();
79
- el.menuModel = NESTED;
79
+ el.menuModel = buildMenu(NESTED);
80
80
  expect(labels(el)).toStrictEqual(['New', 'More']);
81
81
  expect(el.visibleSubmenu).toBe('main');
82
82
  unmountAll();
@@ -84,7 +84,7 @@ export const GtkPopoverMenuTest = async () => {
84
84
 
85
85
  await it('opens a submenu by name and swaps the page', () => {
86
86
  const el = mount();
87
- el.menuModel = NESTED;
87
+ el.menuModel = buildMenu(NESTED);
88
88
  el.openSubmenu('More');
89
89
  // A submenu's page is the back row plus its own items — the submenu's contents
90
90
  // are never inlined into the parent.
@@ -95,7 +95,7 @@ export const GtkPopoverMenuTest = async () => {
95
95
 
96
96
  await it('`main` goes back, and so does closeSubmenus', () => {
97
97
  const el = mount();
98
- el.menuModel = NESTED;
98
+ el.menuModel = buildMenu(NESTED);
99
99
  el.openSubmenu('More');
100
100
  el.openSubmenu('main');
101
101
  expect(labels(el)).toStrictEqual(['New', 'More']);
@@ -109,7 +109,7 @@ export const GtkPopoverMenuTest = async () => {
109
109
 
110
110
  await it('a name no submenu carries leaves the open page alone', () => {
111
111
  const el = mount();
112
- el.menuModel = NESTED;
112
+ el.menuModel = buildMenu(NESTED);
113
113
  el.openSubmenu('More');
114
114
  // `gtk_stack_set_visible_child_name` on a child that is not there is a no-op in
115
115
  // GTK, so the current page must survive rather than blank the surface.
@@ -121,7 +121,7 @@ export const GtkPopoverMenuTest = async () => {
121
121
 
122
122
  await it('`visible-submenu` as an ATTRIBUTE navigates, and notifies once per page', () => {
123
123
  const el = mount();
124
- el.menuModel = NESTED;
124
+ el.menuModel = buildMenu(NESTED);
125
125
  const seen: string[] = [];
126
126
  el.addEventListener('notify::visible-submenu', (event) => {
127
127
  seen.push((event as CustomEvent<{ visibleSubmenu: string }>).detail.visibleSubmenu);
@@ -137,9 +137,9 @@ export const GtkPopoverMenuTest = async () => {
137
137
 
138
138
  await it('a CHANGED model drops the open page', () => {
139
139
  const el = mount();
140
- el.menuModel = NESTED;
140
+ el.menuModel = buildMenu(NESTED);
141
141
  el.openSubmenu('More');
142
- el.menuModel = [{ label: 'Only' }];
142
+ el.menuModel = buildMenu([{ label: 'Only' }]);
143
143
  // The submenu the page named is gone; staying on it would leave a back row and
144
144
  // no items behind it.
145
145
  expect(el.visibleSubmenu).toBe('main');
@@ -149,16 +149,16 @@ export const GtkPopoverMenuTest = async () => {
149
149
 
150
150
  await it('an UNCHANGED model keeps the open page', () => {
151
151
  const el = mount();
152
- el.menuModel = NESTED;
152
+ el.menuModel = buildMenu(NESTED);
153
153
  el.openSubmenu('More');
154
- el.menuModel = JSON.parse(JSON.stringify(NESTED)) as AdwMenuInput;
154
+ el.menuModel = buildMenu(JSON.parse(JSON.stringify(NESTED)));
155
155
  expect(el.visibleSubmenu).toBe('More');
156
156
  unmountAll();
157
157
  });
158
158
 
159
159
  await it('closing the popover returns to the root page', async () => {
160
160
  const el = mount();
161
- el.menuModel = NESTED;
161
+ el.menuModel = buildMenu(NESTED);
162
162
  el.openSubmenu('More');
163
163
  el.open = true;
164
164
  el.open = false;
@@ -175,7 +175,7 @@ export const GtkPopoverMenuTest = async () => {
175
175
  const el = document.createElement('gtk-popover-menu') as PopoverMenuElement;
176
176
  anchor.appendChild(el);
177
177
  document.body.appendChild(anchor);
178
- el.menuModel = NESTED;
178
+ el.menuModel = buildMenu(NESTED);
179
179
  el.openSubmenu('More');
180
180
  el.anchor = anchor;
181
181
  el.open = true;
@@ -197,7 +197,7 @@ export const GtkPopoverMenuTest = async () => {
197
197
 
198
198
  await it('a disabled row activates nothing', () => {
199
199
  const el = mount();
200
- el.menuModel = [{ label: 'Quit', action: 'app.quit' }];
200
+ el.menuModel = buildMenu([{ label: 'Quit', action: 'app.quit' }]);
201
201
  el.actions = { 'app.quit': { enabled: false } };
202
202
  let fired = false;
203
203
  el.addEventListener('menu-item-activated', () => {
@@ -212,11 +212,11 @@ export const GtkPopoverMenuTest = async () => {
212
212
  await describe('<gtk-popover-menu> the menu attribute', async () => {
213
213
  await it('reads the JSON and draws the same rows `flattenMenu` names', () => {
214
214
  const el = mount('[{"label":"New"},{"section":[{"label":"Cut"}],"label":"Edit"}]');
215
- const model: AdwMenuModel = [
215
+ const tree: AdwMenuTree = [
216
216
  { kind: 'item', label: 'New' },
217
217
  { kind: 'section', label: 'Edit', items: [{ kind: 'item', label: 'Cut' }] },
218
218
  ];
219
- expect(labels(el)).toStrictEqual(flattenMenu(model).map((row) => row.node.label));
219
+ expect(labels(el)).toStrictEqual(flattenMenu(tree).map((row) => row.node.label));
220
220
  unmountAll();
221
221
  });
222
222
 
@@ -243,15 +243,15 @@ export const GtkPopoverMenuTest = async () => {
243
243
  });
244
244
 
245
245
  await it('the PROPERTY path refuses a `custom` item loudly', () => {
246
- for (const { model, paths, rule } of MENU_REFUSAL_VECTORS) {
246
+ for (const { tree, paths, rule } of MENU_REFUSAL_VECTORS) {
247
247
  const el = mount();
248
248
  if (paths.length > 0) {
249
249
  expect(() => {
250
- el.menuModel = model;
250
+ el.menuModel = buildMenu(tree);
251
251
  }).toThrow('adwaita-web');
252
252
  } else {
253
- el.menuModel = model;
254
- expect(labels(el)).toStrictEqual(flattenMenu(model).map((row) => row.node.label));
253
+ el.menuModel = buildMenu(tree);
254
+ expect(labels(el)).toStrictEqual(flattenMenu(tree).map((row) => row.node.label));
255
255
  }
256
256
  expect(rule.length > 0).toBe(true);
257
257
  el.remove();
@@ -263,7 +263,7 @@ export const GtkPopoverMenuTest = async () => {
263
263
  for (const { label, text, rule } of MENU_LABEL_TEXT_VECTORS) {
264
264
  await it(`item, submenu and back row: ${rule}`, () => {
265
265
  const el = mount();
266
- el.menuModel = [{ label }, { label, submenu: [{ label }] }];
266
+ el.menuModel = buildMenu([{ label }, { label, submenu: [{ label }] }]);
267
267
  expect(labels(el)).toStrictEqual([text, text]);
268
268
  el.openSubmenu(label);
269
269
  expect(labels(el)).toStrictEqual([text, text]);