@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.
- package/lib/types/application.d.ts +3 -3
- package/lib/types/elements/adw-application-window.d.ts +4 -4
- package/lib/types/elements/adw-bin.d.ts +13 -0
- 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/lib/types/widget-element.d.ts +8 -2
- package/package.json +12 -13
- package/src/adw-application-window.spec.ts +5 -2
- package/src/adw-bin.spec.ts +20 -0
- package/src/application.ts +4 -4
- package/src/elements/adw-application-window.ts +17 -21
- package/src/elements/adw-bin.ts +20 -1
- 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/pango-markup.spec.ts +11 -0
- package/src/pango-markup.ts +5 -2
- package/src/shared-tree-builder.ts +6 -1
- package/src/split-button.spec.ts +25 -8
- package/src/widget-element.ts +28 -7
- package/src/widget-members.spec.ts +42 -3
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
|
});
|
|
@@ -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 {
|
|
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:
|
|
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))
|
|
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
|
|
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(
|
|
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 {
|
|
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 =
|
|
250
|
+
el.menuModel = buildMenu(tree);
|
|
251
251
|
}).toThrow('adwaita-web');
|
|
252
252
|
} else {
|
|
253
|
-
el.menuModel =
|
|
254
|
-
expect(labels(el)).toStrictEqual(flattenMenu(
|
|
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]);
|