@gjsify/adwaita-web 0.64.13 → 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.
- package/lib/types/application.d.ts +3 -3
- package/lib/types/elements/adw-application-window.d.ts +4 -4
- package/lib/types/elements/adw-split-button.d.ts +19 -28
- package/lib/types/elements/adw-tab-overview.d.ts +4 -4
- package/lib/types/elements/gtk-menu-button.d.ts +30 -42
- package/lib/types/elements/gtk-popover-bin.d.ts +18 -11
- package/lib/types/elements/gtk-popover-menu-bar.d.ts +15 -8
- package/lib/types/elements/gtk-popover-menu.d.ts +13 -15
- package/lib/types/elements/menu-slot.d.ts +49 -0
- package/lib/types/elements/popover-menu.d.ts +11 -7
- package/lib/types/index.d.ts +2 -1
- package/lib/types/namespace/gio.d.ts +1 -1
- package/package.json +11 -11
- package/src/adw-application-window.spec.ts +5 -2
- package/src/application.ts +4 -4
- package/src/elements/adw-application-window.ts +17 -21
- package/src/elements/adw-split-button.ts +35 -59
- package/src/elements/adw-tab-overview.ts +18 -28
- package/src/elements/gtk-menu-button.ts +60 -82
- package/src/elements/gtk-popover-bin.ts +32 -38
- package/src/elements/gtk-popover-menu-bar.ts +41 -46
- package/src/elements/gtk-popover-menu.ts +35 -54
- package/src/elements/menu-slot.ts +139 -0
- package/src/elements/popover-menu.ts +26 -16
- package/src/gio-menu.spec.ts +173 -34
- package/src/gtk-image.spec.ts +3 -2
- package/src/gtk-menu-button.spec.ts +19 -16
- package/src/gtk-popover-bin.spec.ts +6 -5
- package/src/gtk-popover-menu-bar.spec.ts +11 -6
- package/src/gtk-popover-menu.spec.ts +22 -22
- package/src/index.ts +10 -2
- package/src/keyboard-operable.spec.ts +6 -5
- package/src/menu-actions.spec.ts +6 -1
- package/src/namespace/gio.ts +19 -2
- package/src/shared-tree-builder.ts +6 -1
- package/src/split-button.spec.ts +25 -8
|
@@ -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,
|
|
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
|
|
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
|
|
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,
|
|
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.
|
|
188
|
-
* collection call by the setter's name.
|
|
192
|
+
* remove.
|
|
189
193
|
*/
|
|
190
|
-
|
|
191
|
-
// A CHANGED
|
|
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
|
-
|
|
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.
|
|
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():
|
|
219
|
-
if (this._page.length === 0) return this.
|
|
220
|
-
const node = menuNodeAt(this.
|
|
221
|
-
return node !== null && node.kind !== 'item' ? node.items : this.
|
|
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.
|
|
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.
|
|
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
|
|
package/src/gio-menu.spec.ts
CHANGED
|
@@ -1,46 +1,85 @@
|
|
|
1
|
-
// `Gio.Menu` as an authoring door
|
|
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
|
|
4
|
-
// and the same menu WRITTEN as
|
|
5
|
-
// cannot tell them apart. Every case
|
|
6
|
-
// `normalizeMenuModel(<the
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
26
|
-
await it('
|
|
27
|
-
|
|
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().
|
|
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
|
|
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(
|
|
43
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
};
|
package/src/gtk-image.spec.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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,
|
|
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(
|
|
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:
|
|
182
|
+
menu_model: AdwMenuModel | null;
|
|
183
183
|
get_menu_model(): unknown;
|
|
184
|
-
set_menu_model(v:
|
|
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 {
|
|
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(
|
|
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
|
|
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:
|
|
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).
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
19
|
-
|
|
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
|
});
|