@gjsify/adwaita-web 0.47.0 → 0.48.0

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.
@@ -1,20 +1,29 @@
1
- // DOM-level tests for <gtk-menu-button>'s `menu` attribute.
1
+ // DOM-level tests for <gtk-menu-button>'s `menu-model` attribute.
2
2
  //
3
3
  // The element parsed the JSON itself, and the copy was weaker than the core parser
4
4
  // the split button next to it already used: it kept `id` and `icon` whatever their
5
5
  // runtime type, so author markup could put a number in a field typed `string` — and
6
- // `id` is exactly what the activation event reports. These drive the core's parse
7
- // vectors against the real element.
6
+ // `id` is exactly what the activation event reports. These drive the core's parse and
7
+ // flatten vectors against the real element.
8
8
 
9
9
  import { describe, expect, it } from '@gjsify/unit';
10
- import { SPLIT_BUTTON_MENU_PARSE_VECTORS } from '@gjsify/adwaita-core/conformance';
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
13
 
12
- /** Mount a menu button carrying `menu` as JSON, and return its rendered item labels. */
14
+ interface MenuButtonElement extends HTMLElement {
15
+ menuModel: AdwMenuInput;
16
+ actions: Record<string, { enabled?: boolean; state?: string }> | null;
17
+ }
18
+
19
+ /** Mount a menu button carrying `menu-model` as JSON, and return its rendered row labels. */
13
20
  function mountWithMenu(json: string): { el: HTMLElement; labels: string[] } {
14
21
  const el = document.createElement('gtk-menu-button');
15
- el.setAttribute('menu', json);
22
+ el.setAttribute('menu-model', json);
16
23
  document.body.appendChild(el);
17
- const labels = Array.from(el.querySelectorAll('.adw-popover button')).map((b) => b.textContent?.trim() ?? '');
24
+ const labels = Array.from(el.querySelectorAll('.adw-popover-item')).map(
25
+ (b) => b.querySelector('.adw-menu-button-item-label')?.textContent ?? '',
26
+ );
18
27
  return { el, labels };
19
28
  }
20
29
 
@@ -24,24 +33,26 @@ function unmountAll(): void {
24
33
 
25
34
  export const GtkMenuButtonTest = async () => {
26
35
  await describe('<gtk-menu-button> menu attribute', async () => {
27
- for (const { json, entries, rule } of SPLIT_BUTTON_MENU_PARSE_VECTORS) {
36
+ for (const { json, model, rule } of MENU_PARSE_VECTORS) {
28
37
  await it(`${rule}`, () => {
29
38
  const { labels } = mountWithMenu(json ?? '');
30
- expect(labels).toStrictEqual(entries.map((entry) => entry.label));
39
+ // The rendered rows are what `flattenMenu` says: a section is inlined,
40
+ // a submenu is ONE row, and both keep their own label.
41
+ expect(labels).toStrictEqual(flattenMenu(model).map((row) => row.node.label));
31
42
  unmountAll();
32
43
  });
33
44
  }
34
45
 
35
46
  await it('drops a non-string id instead of leaking a number into the event', () => {
36
47
  const el = document.createElement('gtk-menu-button');
37
- el.setAttribute('menu', '[{"label":"Open","id":7}]');
48
+ el.setAttribute('menu-model', '[{"label":"Open","id":7}]');
38
49
  document.body.appendChild(el);
39
50
 
40
51
  let seen: unknown = 'not fired';
41
52
  el.addEventListener('menu-item-activated', (e) => {
42
53
  seen = (e as CustomEvent<{ id: string }>).detail.id;
43
54
  });
44
- (el.querySelector('.adw-popover button') as HTMLElement | null)?.click();
55
+ (el.querySelector('.adw-popover-item') as HTMLElement | null)?.click();
45
56
 
46
57
  // `id` fell back to the label, which is the documented default — rather
47
58
  // than arriving as the number 7 in a field every consumer reads as text.
@@ -49,4 +60,147 @@ export const GtkMenuButtonTest = async () => {
49
60
  unmountAll();
50
61
  });
51
62
  });
63
+
64
+ await describe('<gtk-menu-button> keeps its menu replaceable while live', async () => {
65
+ // The property `check-adwaita-collection-reactivity.mjs` exists to protect, and
66
+ // the one it cannot see here: this widget keeps its model in its own field rather
67
+ // than in a core state class, and parses its attribute through a MODULE function,
68
+ // so every rule in that reader is silent on it. A spec stands where the reader
69
+ // cannot.
70
+ await it('a menuModel assigned AFTER connect reaches the rows', () => {
71
+ const el = document.createElement('gtk-menu-button') as MenuButtonElement;
72
+ document.body.appendChild(el);
73
+ expect(el.querySelectorAll('.adw-popover-item').length).toBe(0);
74
+ el.menuModel = ['New', 'Open'];
75
+ expect([...el.querySelectorAll('.adw-menu-button-item-label')].map((n) => n.textContent)).toStrictEqual([
76
+ 'New',
77
+ 'Open',
78
+ ]);
79
+ // REPLACED, not merely seeded — the failure mode is a second assignment
80
+ // writing an expando while the first happened to work at connect.
81
+ el.menuModel = [{ label: 'Only', action: 'app.only' }];
82
+ expect([...el.querySelectorAll('.adw-menu-button-item-label')].map((n) => n.textContent)).toStrictEqual([
83
+ 'Only',
84
+ ]);
85
+ unmountAll();
86
+ });
87
+
88
+ await it('a menu-model ATTRIBUTE set after connect reaches them too', () => {
89
+ const el = document.createElement('gtk-menu-button') as MenuButtonElement;
90
+ document.body.appendChild(el);
91
+ el.setAttribute('menu-model', '[{"label":"Later"}]');
92
+ expect([...el.querySelectorAll('.adw-menu-button-item-label')].map((n) => n.textContent)).toStrictEqual([
93
+ 'Later',
94
+ ]);
95
+ unmountAll();
96
+ });
97
+ });
98
+
99
+ await describe('<gtk-menu-button> renders the whole model (ADR 0042)', async () => {
100
+ await it('draws a section heading, a separator and a submenu page', () => {
101
+ const el = document.createElement('gtk-menu-button') as MenuButtonElement;
102
+ document.body.appendChild(el);
103
+ el.menuModel = [
104
+ { label: 'New' },
105
+ { section: [{ label: 'Cut' }, { label: 'Copy' }], label: 'Edit' },
106
+ { label: 'More', submenu: [{ label: 'Rename' }, { label: 'Duplicate' }] },
107
+ ];
108
+
109
+ // TWO: a section is separated from what comes before it AND from what comes
110
+ // after, which is what `GtkPopoverMenu` draws for the same model.
111
+ expect(el.querySelectorAll('.adw-popover-separator').length).toBe(2);
112
+ expect(el.querySelector('.adw-popover-section-title')?.textContent).toBe('Edit');
113
+ const rows = [...el.querySelectorAll('.adw-popover-item')] as HTMLButtonElement[];
114
+ expect(rows.length).toBe(4);
115
+ // The submenu is a row that OPENS, not a row that acts.
116
+ expect(rows[3].getAttribute('aria-haspopup')).toBe('menu');
117
+
118
+ rows[3].click();
119
+ const page = [...el.querySelectorAll('.adw-popover-item')] as HTMLButtonElement[];
120
+ // A back row plus the submenu's two items — the submenu's contents were
121
+ // never inlined into the parent page.
122
+ expect(page.length).toBe(3);
123
+ expect(page[0].classList.contains('adw-popover-back')).toBe(true);
124
+ expect(page.map((r) => r.querySelector('.adw-menu-button-item-label')?.textContent)).toStrictEqual([
125
+ 'More',
126
+ 'Rename',
127
+ 'Duplicate',
128
+ ]);
129
+
130
+ page[0].click();
131
+ expect(el.querySelectorAll('.adw-popover-item').length).toBe(4);
132
+ unmountAll();
133
+ });
134
+
135
+ for (const { item, actions, state, rule } of MENU_ITEM_STATE_VECTORS) {
136
+ await it(`action state reaches the DOM — ${rule}`, () => {
137
+ const el = document.createElement('gtk-menu-button') as MenuButtonElement;
138
+ document.body.appendChild(el);
139
+ el.actions = { ...actions };
140
+ el.menuModel = [item];
141
+ const row = el.querySelector('.adw-popover-item') as HTMLButtonElement;
142
+ expect(row.hidden).toBe(!state.visible);
143
+ expect(row.disabled).toBe(!state.sensitive);
144
+ const expectedRole =
145
+ state.role === 'normal'
146
+ ? 'menuitem'
147
+ : state.role === 'check'
148
+ ? 'menuitemcheckbox'
149
+ : 'menuitemradio';
150
+ expect(row.getAttribute('role')).toBe(expectedRole);
151
+ expect(row.getAttribute('aria-checked')).toBe(state.role === 'normal' ? null : String(state.toggled));
152
+ unmountAll();
153
+ });
154
+ }
155
+
156
+ await it('the ATTRIBUTE path refuses too — markup is where a blank row actually ships', () => {
157
+ // The first cut asserted only the property setter, so a `custom` item written
158
+ // in markup drew exactly the row the refusal exists to prevent.
159
+ //
160
+ // It does NOT throw, and that is measured rather than chosen: a throw from
161
+ // `connectedCallback` is not delivered to whoever appended the element, it is
162
+ // reported as an uncaught PAGE error — it broke `adwaita-upgrade-order`,
163
+ // which counts those. So the menu is refused whole (a menu-less dropdown is
164
+ // insensitive, which is visible) and the reason goes to `console.error`.
165
+ const errors: unknown[] = [];
166
+ const real = console.error;
167
+ console.error = (...args: unknown[]) => errors.push(args.join(' '));
168
+ try {
169
+ const el = document.createElement('gtk-menu-button');
170
+ el.setAttribute('menu-model', '[{"label":"Zoom","custom":"zoom-control"},{"label":"Quit"}]');
171
+ document.body.appendChild(el);
172
+ expect(el.querySelectorAll('.adw-popover-item').length).toBe(0);
173
+ expect(errors.join('\n')).toContain('zoom-control');
174
+ } finally {
175
+ console.error = real;
176
+ }
177
+ unmountAll();
178
+ });
179
+
180
+ await it('a malformed attribute is NOT a refusal — a typo may not stop the upgrade', () => {
181
+ // The other half of the same rule: `parseMenuModel` stays total, so this
182
+ // element upgrades with no menu instead of throwing.
183
+ const { labels } = mountWithMenu('not json at all');
184
+ expect(labels).toStrictEqual([]);
185
+ unmountAll();
186
+ });
187
+
188
+ for (const { model, paths, rule } of MENU_REFUSAL_VECTORS) {
189
+ await it(`refusal — ${rule}`, () => {
190
+ const el = document.createElement('gtk-menu-button') as MenuButtonElement;
191
+ document.body.appendChild(el);
192
+ if (paths.length === 0) {
193
+ el.menuModel = model;
194
+ expect(el.querySelectorAll('.adw-popover-item').length).toBe(flattenMenu(model).length);
195
+ } else {
196
+ // LOUD, at the assignment: a `custom` item names an application
197
+ // widget, and a surface that ignored it would draw a blank row.
198
+ expect(() => {
199
+ el.menuModel = model;
200
+ }).toThrow('adwaita-web');
201
+ }
202
+ unmountAll();
203
+ });
204
+ }
205
+ });
52
206
  };
package/src/index.ts CHANGED
@@ -134,6 +134,19 @@ export { AdwAlertResponse } from './elements/adw-alert-dialog.js';
134
134
  // return. Not widgets, so clause 2 has nothing to say about them.
135
135
  export type { GtkPopoverAlign, GtkPopoverPosition, GtkPopoverRole } from './elements/gtk-popover.js';
136
136
  export type { AdwViewStackPageInfo } from './elements/adw-view-stack.js';
137
- export type { AdwMenuItem } from './elements/gtk-menu-button.js';
137
+ // The portable menu model (ADR 0042) is `@gjsify/adwaita-core`'s, re-exported so a
138
+ // consumer of THIS package reaches the menu types without a second dependency — one
139
+ // definition, two doors, never two declarations.
140
+ export type {
141
+ AdwMenuActions,
142
+ AdwMenuEntryInput,
143
+ AdwMenuInput,
144
+ AdwMenuItem,
145
+ AdwMenuModel,
146
+ AdwMenuNode,
147
+ AdwMenuPath,
148
+ AdwMenuSection,
149
+ AdwMenuSubmenu,
150
+ } from '@gjsify/adwaita-core';
138
151
  export type { GtkDropDownOption } from './elements/gtk-drop-down.js';
139
152
  export type { AdwDialogPresentationMode } from './elements/adw-dialog.js';
@@ -685,6 +685,175 @@ export const AdwKeyboardOperableTest = async () => {
685
685
  });
686
686
  });
687
687
 
688
+ await describe('a portable menu is traversable, page by page (ADR 0042)', async () => {
689
+ // The roving tabindex on a menu row moved into `PopoverMenuView` when the two
690
+ // menu buttons started sharing one popup, and it took its keyboard obligation
691
+ // with it. `check-adwaita-keyboard-contract.mjs` holds that a listener EXISTS;
692
+ // this holds that the keys move focus — the half a static reader cannot have.
693
+ const open = (): { el: HTMLElement; rows: () => HTMLButtonElement[] } => {
694
+ const el = document.createElement('gtk-menu-button') as HTMLElement & {
695
+ menuModel: unknown;
696
+ actions: unknown;
697
+ };
698
+ document.body.appendChild(el);
699
+ el.actions = { 'app.export': { enabled: false }, 'app.new': {} };
700
+ el.menuModel = [
701
+ { label: 'New', action: 'app.new' },
702
+ { section: [{ label: 'Export', action: 'app.export' }, { label: 'Print' }] },
703
+ { label: 'More', submenu: [{ label: 'Rename' }, { label: 'Duplicate' }] },
704
+ ];
705
+ (el.querySelector('.adw-menu-button-button') as HTMLElement).click();
706
+ return {
707
+ el,
708
+ rows: () => [...el.querySelectorAll<HTMLButtonElement>('.adw-popover-item')],
709
+ };
710
+ };
711
+ const labelOf = (el: Element | null) =>
712
+ el?.querySelector('.adw-menu-button-item-label')?.textContent ?? el?.localName ?? 'none';
713
+
714
+ await it('the arrow keys walk the rows and wrap, and Home/End jump', () => {
715
+ const { el, rows } = open();
716
+ const items = rows();
717
+ // Four ROWS, but only three are navigable: `app.export` is disabled by the
718
+ // action group, and a disabled button cannot take focus.
719
+ expect(items.length).toBe(4);
720
+ expect(items[1].disabled).toBe(true);
721
+
722
+ items[0].focus();
723
+ press(items[0], 'ArrowDown');
724
+ // Straight past the disabled row — an arrow that lands on it is a dead press.
725
+ expect(labelOf(document.activeElement)).toBe('Print');
726
+ press(document.activeElement as HTMLElement, 'ArrowDown');
727
+ expect(labelOf(document.activeElement)).toBe('More');
728
+ // A menu popover WRAPS, unlike the tab lists: `resolvePopoverKey` is modular.
729
+ press(document.activeElement as HTMLElement, 'ArrowDown');
730
+ expect(labelOf(document.activeElement)).toBe('New');
731
+
732
+ press(document.activeElement as HTMLElement, 'End');
733
+ expect(labelOf(document.activeElement)).toBe('More');
734
+ press(document.activeElement as HTMLElement, 'Home');
735
+ expect(labelOf(document.activeElement)).toBe('New');
736
+ el.remove();
737
+ });
738
+
739
+ await it('neither a section heading nor a separator is a row', () => {
740
+ const { el } = open();
741
+ // Both are `<div>`s without `.adw-popover-item`, so the walk cannot land on
742
+ // them — asserted rather than assumed, because the class is what decides it.
743
+ expect(el.querySelectorAll('.adw-popover-separator').length).toBe(2);
744
+ for (const node of el.querySelectorAll('.adw-popover-separator, .adw-popover-title')) {
745
+ expect(node.classList.contains('adw-popover-item')).toBe(false);
746
+ }
747
+ el.remove();
748
+ });
749
+
750
+ await it('ArrowRight opens a submenu and ArrowLeft leaves it — the row answers, not the surface', () => {
751
+ const { el, rows } = open();
752
+ const opener = rows()[3];
753
+ expect(labelOf(opener)).toBe('More');
754
+
755
+ opener.focus();
756
+ // `gtk_model_button_focus`: GTK_DIR_RIGHT on a row with a `menu-name`
757
+ // (gtkmodelbutton.c:1189-1195).
758
+ const right = press(opener, 'ArrowRight');
759
+ expect(right.defaultPrevented).toBe(true);
760
+ expect(rows().map(labelOf)).toStrictEqual(['More', 'Rename', 'Duplicate']);
761
+ // Focus lands on the first ITEM of the page, past the back row.
762
+ expect(labelOf(document.activeElement)).toBe('Rename');
763
+
764
+ // GTK_DIR_LEFT answers only on the TITLE row (:1182-1188), so from the
765
+ // middle of the page it does nothing — deliberately the same here.
766
+ const inert = press(document.activeElement as HTMLElement, 'ArrowLeft');
767
+ expect(inert.defaultPrevented).toBe(false);
768
+ expect(labelOf(document.activeElement)).toBe('Rename');
769
+
770
+ const back = rows()[0];
771
+ back.focus();
772
+ const left = press(back, 'ArrowLeft');
773
+ expect(left.defaultPrevented).toBe(true);
774
+ expect(rows().map(labelOf)).toStrictEqual(['New', 'Export', 'Print', 'More']);
775
+ el.remove();
776
+ });
777
+
778
+ await it('a page change never strands focus outside the popup, whatever is disabled', () => {
779
+ // K1. Both page changes focused a HARD-CODED index of `_rows`, which includes
780
+ // rows no key can reach — so entering a submenu whose first item is disabled
781
+ // put `document.activeElement` on <body>, OUTSIDE an open popover: Tab then
782
+ // walked to the control behind it and every arrow was dead, because both
783
+ // keydown listeners are element-scoped. The two ELEMENTS already filtered on
784
+ // their open path; the two page changes did not, which is two of four.
785
+ const el = document.createElement('gtk-menu-button') as HTMLElement & {
786
+ menuModel: unknown;
787
+ actions: unknown;
788
+ };
789
+ document.body.appendChild(el);
790
+ el.actions = { 'app.off': { enabled: false } };
791
+ el.menuModel = [
792
+ { label: 'Dim', action: 'app.off' },
793
+ { label: 'Live' },
794
+ {
795
+ label: 'More',
796
+ submenu: [{ label: 'SubDim', action: 'app.off' }, { label: 'SubLive' }],
797
+ },
798
+ ];
799
+ (el.querySelector('.adw-menu-button-button') as HTMLElement).click();
800
+ const rows = () => [...el.querySelectorAll<HTMLButtonElement>('.adw-popover-item')];
801
+ const label = () =>
802
+ document.activeElement?.querySelector('.adw-menu-button-item-label')?.textContent ??
803
+ document.activeElement?.localName ??
804
+ 'none';
805
+ const inside = () => el.contains(document.activeElement);
806
+
807
+ // ENTERING: past the back row, counted in REACHABLE rows — not index 1.
808
+ const opener = rows()[2] as HTMLButtonElement;
809
+ opener.focus();
810
+ press(opener, 'ArrowRight');
811
+ expect(inside()).toBe(true);
812
+ expect(label()).toBe('SubLive');
813
+
814
+ // LEAVING: the parent's first row is disabled too, so index 0 would strand.
815
+ const back = rows()[0] as HTMLButtonElement;
816
+ back.focus();
817
+ press(back, 'ArrowLeft');
818
+ expect(inside()).toBe(true);
819
+ expect(label()).toBe('Live');
820
+ el.remove();
821
+ });
822
+
823
+ await it('a submenu with NOTHING reachable focuses its back row, not the page', () => {
824
+ const el = document.createElement('gtk-menu-button') as HTMLElement & {
825
+ menuModel: unknown;
826
+ actions: unknown;
827
+ };
828
+ document.body.appendChild(el);
829
+ el.actions = { 'app.off': { enabled: false } };
830
+ el.menuModel = [{ label: 'More', submenu: [{ label: 'Only', action: 'app.off' }] }];
831
+ (el.querySelector('.adw-menu-button-button') as HTMLElement).click();
832
+ const opener = el.querySelector('.adw-popover-item') as HTMLButtonElement;
833
+ opener.focus();
834
+ press(opener, 'ArrowRight');
835
+ // The back row is always reachable, so the reader can still leave.
836
+ expect(el.contains(document.activeElement)).toBe(true);
837
+ expect(document.activeElement?.classList.contains('adw-popover-back')).toBe(true);
838
+ el.remove();
839
+ });
840
+
841
+ await it('Enter activates the focused row, and reports its path', () => {
842
+ const { el, rows } = open();
843
+ const seen: number[][] = [];
844
+ el.addEventListener('menu-item-activated', (event) => {
845
+ seen.push((event as CustomEvent<{ path: number[] }>).detail.path);
846
+ });
847
+ const print = rows()[2];
848
+ print.focus();
849
+ press(print, 'Enter');
850
+ // Inside a section, so only a PATH names it — the flat index would be 2 here
851
+ // and 1 in the model.
852
+ expect(seen).toStrictEqual([[1, 1]]);
853
+ el.remove();
854
+ });
855
+ });
856
+
688
857
  await describe('the row family is a tab stop where libadwaita makes one', async () => {
689
858
  const mount = (markup: string) => {
690
859
  const host = document.createElement('div');
@@ -728,7 +897,7 @@ export const AdwKeyboardOperableTest = async () => {
728
897
  await it('activates on Enter and Space, and lets a child keep its own keys', () => {
729
898
  const host = mount(
730
899
  '<adw-action-row id="k-keys" title="Keys" activatable></adw-action-row>' +
731
- '<adw-combo-row id="k-combo" title="Combo" items=\'["a","b"]\'></adw-combo-row>',
900
+ '<adw-combo-row id="k-combo" title="Combo" model=\'["a","b"]\'></adw-combo-row>',
732
901
  );
733
902
  const row = document.getElementById('k-keys') as HTMLElement;
734
903
  let activated = 0;
@@ -13,10 +13,11 @@ import {
13
13
  POPOVER_SURFACE_VECTORS,
14
14
  SPLIT_BUTTON_CONTENT_VECTORS,
15
15
  SPLIT_BUTTON_DIRECTION_VECTORS,
16
- SPLIT_BUTTON_MENU_PARSE_VECTORS,
16
+ MENU_NORMALIZE_VECTORS,
17
17
  SPLIT_BUTTON_STYLE_CLASS_VECTORS,
18
18
  SPLIT_BUTTON_TOOLTIP_VECTORS,
19
19
  } from '@gjsify/adwaita-core/conformance';
20
+ import { ADW_MENU_SURFACE_WEB, flattenMenu, menuRefusals } from '@gjsify/adwaita-core';
20
21
  import type { SplitButtonProperty } from '@gjsify/adwaita-core';
21
22
  import type { AdwSplitButton } from './elements/adw-split-button.js';
22
23
  import { fallbackMask, maskOf } from './icon-registry.spec.js';
@@ -234,11 +235,24 @@ export const AdwSplitButtonTest = async () => {
234
235
  });
235
236
 
236
237
  await describe('adw-split-button menu', async () => {
237
- for (const { json, entries, rule } of SPLIT_BUTTON_MENU_PARSE_VECTORS) {
238
- await it(`parses ${JSON.stringify(json)} into ${entries.length} entries — ${rule}`, () => {
239
- const { el, host } = mount(json === null ? {} : { menu: json });
240
- expect([...el.menuItems]).toStrictEqual([...entries]);
241
- expect(el.querySelectorAll('.adw-split-button-menu-item').length).toBe(entries.length);
238
+ for (const { input, model, rule } of MENU_NORMALIZE_VECTORS) {
239
+ await it(`normalises an authored menu — ${rule}`, () => {
240
+ const { el, host } = mount({ label: 'Save' });
241
+ // What THIS surface refuses is core's answer, not a list restated here:
242
+ // a `custom` item names an application widget the browser cannot host,
243
+ // and the setter is where that is said out loud.
244
+ if (menuRefusals(model, ADW_MENU_SURFACE_WEB).length > 0) {
245
+ expect(() => {
246
+ el.menuModel = input;
247
+ }).toThrow('adwaita-web');
248
+ host.remove();
249
+ return;
250
+ }
251
+ el.menuModel = input;
252
+ expect([...el.menuModel]).toStrictEqual([...model]);
253
+ // Every node the model draws is a row: a section is inlined, a submenu
254
+ // is one row of its own.
255
+ expect(el.querySelectorAll('.adw-split-button-menu-item').length).toBe(flattenMenu(model).length);
242
256
  host.remove();
243
257
  });
244
258
  }
@@ -255,7 +269,7 @@ export const AdwSplitButtonTest = async () => {
255
269
  });
256
270
 
257
271
  await it('opens and closes on the dropdown half once there is a menu', () => {
258
- const { el, host } = mount({ menu: '[{"label":"Save as…","action":"app.save-as"}]' });
272
+ const { el, host } = mount({ 'menu-model': '[{"label":"Save as…","action":"app.save-as"}]' });
259
273
  expect(el.dropdownEnabled).toBe(true);
260
274
  expect(dropdownHalf(el).disabled).toBe(false);
261
275
 
@@ -275,25 +289,25 @@ export const AdwSplitButtonTest = async () => {
275
289
 
276
290
  await it('dispatches a duplicate label BY POSITION, with its own action', () => {
277
291
  const { el, host } = mount({
278
- menu: '[{"label":"Copy","action":"app.copy"},{"label":"Copy","action":"app.copy-special"}]',
292
+ 'menu-model': '[{"label":"Copy","action":"app.copy"},{"label":"Copy","action":"app.copy-special"}]',
279
293
  });
280
- const activated: Array<{ label: string; action?: string; index: number }> = [];
294
+ const activated: Array<{ label: string; action?: string; id: string; path: number[] }> = [];
281
295
  el.addEventListener('menu-activated', (event) => activated.push((event as CustomEvent).detail));
282
296
 
283
297
  dropdownHalf(el).click();
284
298
  const items = el.querySelectorAll('.adw-split-button-menu-item');
285
299
  (items[1] as HTMLButtonElement).click();
286
300
 
287
- expect(activated).toStrictEqual([{ label: 'Copy', action: 'app.copy-special', index: 1 }]);
301
+ expect(activated).toStrictEqual([{ label: 'Copy', action: 'app.copy-special', id: 'Copy', path: [1] }]);
288
302
  expect(el.active).toBe(false);
289
303
  host.remove();
290
304
  });
291
305
 
292
306
  await it('losing its menu closes an open one', () => {
293
- const { el, host } = mount({ menu: '[{"label":"Print"}]' });
307
+ const { el, host } = mount({ 'menu-model': '[{"label":"Print"}]' });
294
308
  dropdownHalf(el).click();
295
309
  expect(el.active).toBe(true);
296
- el.setAttribute('menu', '[]');
310
+ el.setAttribute('menu-model', '[]');
297
311
  expect(el.active).toBe(false);
298
312
  expect(el.dropdownEnabled).toBe(false);
299
313
  host.remove();
@@ -316,7 +330,7 @@ export const AdwSplitButtonTest = async () => {
316
330
  });
317
331
 
318
332
  await it('marks the root checked while the menu is open (the update_state fold)', () => {
319
- const { el, host } = mount({ menu: '[{"label":"Print"}]' });
333
+ const { el, host } = mount({ 'menu-model': '[{"label":"Print"}]' });
320
334
  expect(el.classList.contains('checked')).toBe(false);
321
335
  dropdownHalf(el).click();
322
336
  expect(el.classList.contains('checked')).toBe(true);
@@ -331,7 +345,7 @@ export const AdwSplitButtonTest = async () => {
331
345
  // from `<gtk-popover>`, and these cases prove the lift was a lift, not a move.
332
346
  await describe('adw-split-button menu keyboard (gained with <gtk-popover>)', async () => {
333
347
  await it('Escape dismisses the menu and returns focus to the dropdown half', () => {
334
- const { el, host } = mount({ menu: '[{"label":"Print"},{"label":"Export"}]' });
348
+ const { el, host } = mount({ 'menu-model': '[{"label":"Print"},{"label":"Export"}]' });
335
349
  dropdownHalf(el).click();
336
350
  expect(el.active).toBe(true);
337
351
 
@@ -346,7 +360,7 @@ export const AdwSplitButtonTest = async () => {
346
360
  });
347
361
 
348
362
  await it('Escape reaches notify::active, so the dismissal is a real state change', () => {
349
- const { el, host } = mount({ menu: '[{"label":"Print"}]' });
363
+ const { el, host } = mount({ 'menu-model': '[{"label":"Print"}]' });
350
364
  const seen: boolean[] = [];
351
365
  el.addEventListener('notify::active', (event) => seen.push((event as CustomEvent).detail.active));
352
366
 
@@ -358,7 +372,7 @@ export const AdwSplitButtonTest = async () => {
358
372
  });
359
373
 
360
374
  await it('the arrow keys walk the menu items and wrap, from the row focused on open', () => {
361
- const { el, host } = mount({ menu: '[{"label":"Print"},{"label":"Export"},{"label":"Share"}]' });
375
+ const { el, host } = mount({ 'menu-model': '[{"label":"Print"},{"label":"Export"},{"label":"Share"}]' });
362
376
  dropdownHalf(el).click();
363
377
 
364
378
  // Queried AFTER opening on purpose: `_renderMenu` runs on every state change
@@ -391,9 +405,9 @@ export const AdwSplitButtonTest = async () => {
391
405
  });
392
406
 
393
407
  await it('Enter on a focused row activates it BY POSITION, exactly once', () => {
394
- const { el, host } = mount({ menu: '[{"label":"Copy"},{"label":"Copy"}]' });
395
- const seen: number[] = [];
396
- el.addEventListener('menu-activated', (event) => seen.push((event as CustomEvent).detail.index));
408
+ const { el, host } = mount({ 'menu-model': '[{"label":"Copy"},{"label":"Copy"}]' });
409
+ const seen: number[][] = [];
410
+ el.addEventListener('menu-activated', (event) => seen.push((event as CustomEvent).detail.path));
397
411
 
398
412
  dropdownHalf(el).click();
399
413
  const menu = el.querySelector('gtk-popover') as HTMLElement;
@@ -403,7 +417,7 @@ export const AdwSplitButtonTest = async () => {
403
417
  // Two entries share a label, so only position tells them apart; and
404
418
  // `preventDefault()` before the synthetic click keeps a focused <button> from
405
419
  // ALSO activating natively and firing twice.
406
- expect(seen).toStrictEqual([1]);
420
+ expect(seen).toStrictEqual([[1]]);
407
421
  host.remove();
408
422
  });
409
423
  });
@@ -423,7 +437,7 @@ export const AdwSplitButtonTest = async () => {
423
437
  });
424
438
 
425
439
  await it(`draws a ${menuVector?.borderRadius}px surface with ${menuVector?.padding}px padding and a THREE-layer shadow`, () => {
426
- const { el, host } = mount({ menu: '[{"label":"Print"}]' });
440
+ const { el, host } = mount({ 'menu-model': '[{"label":"Print"}]' });
427
441
  dropdownHalf(el).click();
428
442
  const surface = el.querySelector('gtk-popover') as HTMLElement;
429
443
  const style = getComputedStyle(surface);
@@ -445,7 +459,7 @@ export const AdwSplitButtonTest = async () => {
445
459
  });
446
460
 
447
461
  await it(`draws menu rows at the ${itemVector?.borderRadius}px modelbutton radius`, () => {
448
- const { el, host } = mount({ menu: '[{"label":"Print"}]' });
462
+ const { el, host } = mount({ 'menu-model': '[{"label":"Print"}]' });
449
463
  dropdownHalf(el).click();
450
464
  const row = el.querySelector('.adw-split-button-menu-item') as HTMLElement;
451
465
  const style = getComputedStyle(row);