@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.
@@ -5,9 +5,10 @@
5
5
  // <adw-combo-row> (which embeds the same idea inside a boxed-list row) — reach
6
6
  // for this one when you need a bare dropdown, not a row.
7
7
  //
8
- // Options are `{ value, label }` pairs (or plain strings, where the string is
9
- // both value and label), supplied via the `options` / `items` property or a
10
- // JSON `options` attribute. Optional `enable-search` puts a search entry atop
8
+ // The model is `{ value, label }` pairs (or plain strings, where the string is
9
+ // both value and label), supplied via the `model` property or a JSON `model`
10
+ // attribute — `Gtk.DropDown:model`'s own name (ADR 0034 clause 1, ADR 0046), which
11
+ // replaced the two spellings `options`/`items`. Optional `enable-search` puts a search entry atop
11
12
  // the popover for long lists. The popover is dismissed on an outside click or
12
13
  // Escape and supports arrow-key navigation, Home/End, Enter to pick, and
13
14
  // type-ahead (typing jumps to the next matching option).
@@ -23,13 +24,21 @@
23
24
  // copy is how ArrowUp from an unfocused list lands on the SECOND-TO-LAST option, which
24
25
  // `enable-search` hits on every open because it focuses the entry.
25
26
  //
27
+ // THE MODEL IS SPLICED, NOT REBUILT (ADR 0046). `ComboState` reports WHERE the model
28
+ // changed — `Gio.ListModel::items-changed`, one splice per assignment — so replacing a
29
+ // model that gained one item inserts ONE row button and leaves the rest, with their focus
30
+ // and their scroll position, alone. It also closed a latent defect the rebuild hid: each
31
+ // row's click handler used to close over the index it was built at, so any splice that
32
+ // shifted a row would have selected the wrong item. The handler now asks the list where
33
+ // the row IS.
34
+ //
26
35
  // Attributes:
27
- // options / items — JSON array: `["a","b"]` or `[{"value":"a","label":"A"}]`.
36
+ // model — JSON array: `["a","b"]` or `[{"value":"a","label":"A"}]`.
28
37
  // selected — the selected index (number).
29
38
  // enable-search — boolean; show a filtering search entry in the popover.
30
39
  // disabled — boolean; a disabled dropdown does not open.
31
40
  // Properties (mirroring Gtk.DropDown):
32
- // options / items — the option list ({ value, label }[]) (get/set).
41
+ // model — the list model ({ value, label }[]) (get/set).
33
42
  // selected — the selected index (get/set; bounds-guarded).
34
43
  // selectedValue — the selected option's value ('' when empty) (get/set-by-value).
35
44
  // selectedItem — the selected option descriptor (or null).
@@ -56,8 +65,8 @@
56
65
  import './gtk-popover.js';
57
66
  import type { GtkPopover } from './gtk-popover.js';
58
67
 
59
- import { ComboState, normalizeComboOptions } from '@gjsify/adwaita-core';
60
- import type { AdwComboOption, AdwComboOptionInput } from '@gjsify/adwaita-core';
68
+ import { ComboState, normalizeComboOptions, parseListModel } from '@gjsify/adwaita-core';
69
+ import type { AdwComboOption, AdwListItemsChanged, AdwListModelInput } from '@gjsify/adwaita-core';
61
70
 
62
71
  import { createGtkImage } from './gtk-image.js';
63
72
 
@@ -82,27 +91,18 @@ export class GtkDropDown extends HTMLElement {
82
91
  private _typeAheadTimer: ReturnType<typeof setTimeout> | null = null;
83
92
 
84
93
  static get observedAttributes() {
85
- return ['options', 'items', 'selected', 'enable-search', 'disabled'];
94
+ return ['model', 'selected', 'enable-search', 'disabled'];
86
95
  }
87
96
 
88
- /** The options list. Setting it rebuilds the popover and clamps the selection. */
89
- get options(): GtkDropDownOption[] {
90
- return this._state.options;
97
+ /** The list model (`Gtk.DropDown:model`). Setting it splices the popover and clamps the selection. */
98
+ get model(): GtkDropDownOption[] {
99
+ return this._state.model;
91
100
  }
92
101
 
93
- set options(value: ReadonlyArray<AdwComboOptionInput>) {
94
- // `setOptions` resets the index to 0 when it no longer fits — the same
102
+ set model(value: AdwListModelInput) {
103
+ // `setModel` resets the index to 0 when it no longer fits — the same
95
104
  // clamp this element used to spell out.
96
- this._state.setOptions(normalizeComboOptions(value));
97
- if (this._initialized) this._renderAll();
98
- }
99
-
100
- get items(): GtkDropDownOption[] {
101
- return this._state.options;
102
- }
103
-
104
- set items(value: ReadonlyArray<AdwComboOptionInput>) {
105
- this.options = value;
105
+ this._state.setModel(normalizeComboOptions(value));
106
106
  }
107
107
 
108
108
  /** The selected index. Out-of-range values are ignored (Gtk.DropDown:selected). */
@@ -120,12 +120,12 @@ export class GtkDropDown extends HTMLElement {
120
120
  }
121
121
 
122
122
  set selectedValue(value: string) {
123
- const index = this._state.options.findIndex((o) => o.value === value);
123
+ const index = this._state.model.findIndex((o) => o.value === value);
124
124
  if (index >= 0) this._selectIndex(index);
125
125
  }
126
126
 
127
127
  get selectedItem(): GtkDropDownOption | null {
128
- return this._state.options[this._state.selectedIndex] ?? null;
128
+ return this._state.model[this._state.selectedIndex] ?? null;
129
129
  }
130
130
 
131
131
  get enableSearch(): boolean {
@@ -178,10 +178,14 @@ export class GtkDropDown extends HTMLElement {
178
178
 
179
179
  // Seed options BEFORE anything subscribes, so building the initial DOM below is
180
180
  // not driven by a change notification.
181
- if (this._state.count === 0) this._state.setOptions(this._parseOptionsAttr());
181
+ if (this._state.count === 0) this._state.setModel(parseListModel(this.getAttribute('model')));
182
182
  const attrSelected = Number.parseInt(this.getAttribute('selected') ?? '', 10);
183
183
  if (this._state.hasIndex(attrSelected)) this._state.setSelectedIndex(attrSelected);
184
184
 
185
+ // WHERE the model changed. Subscribed before the first render so every later
186
+ // assignment reaches the splice rather than a rebuild.
187
+ this._state.subscribeItems((change) => this._applyItemsChanged(change));
188
+
185
189
  this.replaceChildren(this._buttonEl, this._popoverEl);
186
190
  this._popoverEl.anchor = this._buttonEl;
187
191
  this._popoverEl.subscribe((open) => this._onPopoverToggled(open));
@@ -194,9 +198,10 @@ export class GtkDropDown extends HTMLElement {
194
198
 
195
199
  attributeChangedCallback(name: string, _old: string | null, value: string | null) {
196
200
  if (!this._initialized || !this._buttonEl) return;
197
- if (name === 'options' || name === 'items') {
198
- this._state.setOptions(this._parseOptionsAttr());
199
- this._renderAll();
201
+ if (name === 'model') {
202
+ // No render call: the splice subscription does the DOM half, so the markup door
203
+ // and the property door cannot drift into two update paths.
204
+ this._state.setModel(parseListModel(value));
200
205
  } else if (name === 'selected') {
201
206
  const index = Number.parseInt(value ?? '', 10);
202
207
  if (!Number.isNaN(index)) this._selectIndex(index, { fromAttr: true });
@@ -208,25 +213,13 @@ export class GtkDropDown extends HTMLElement {
208
213
  }
209
214
  }
210
215
 
211
- private _parseOptionsAttr(): GtkDropDownOption[] {
212
- const raw = this.getAttribute('options') ?? this.getAttribute('items');
213
- if (!raw) return [];
214
- try {
215
- // `normalizeComboOptions` already guards a non-array, so a JSON object or
216
- // scalar yields the empty model rather than a throw.
217
- return normalizeComboOptions(JSON.parse(raw) as AdwComboOptionInput[]);
218
- } catch {
219
- return [];
220
- }
221
- }
222
-
223
216
  private _selectIndex(index: number, opts: { fromAttr?: boolean; fromUser?: boolean } = {}): void {
224
217
  // The bounds arithmetic is `ComboState`'s; the POLICY of rejecting an
225
218
  // out-of-range set is this element's published `selected` contract, and
226
219
  // `<adw-combo-row>` deliberately answers it the other way (`ComboState.hasIndex`).
227
220
  if (!this._state.hasIndex(index)) return;
228
221
  // Both core setters return "did it change", which is what gates the notify — no
229
- // second copy of the current index is kept here, so `setOptions`' own label
222
+ // second copy of the current index is kept here, so `setModel`' own label
230
223
  // re-sync cannot masquerade as one.
231
224
  const changed = opts.fromUser ? this._state.select(index) : this._state.setSelectedIndex(index);
232
225
  this._updateLabel();
@@ -236,7 +229,7 @@ export class GtkDropDown extends HTMLElement {
236
229
  this.setAttribute('selected', String(index));
237
230
  }
238
231
  if (changed) {
239
- const option = this._state.options[index];
232
+ const option = this._state.model[index];
240
233
  // `notify::selected` mirrors GObject property-notify: EVERY change,
241
234
  // programmatic included.
242
235
  this.dispatchEvent(new CustomEvent('notify::selected', { bubbles: true, detail: { selected: index } }));
@@ -290,7 +283,7 @@ export class GtkDropDown extends HTMLElement {
290
283
  this._typeAheadBuffer += char.toLowerCase();
291
284
  if (this._typeAheadTimer) clearTimeout(this._typeAheadTimer);
292
285
  this._typeAheadTimer = setTimeout(() => (this._typeAheadBuffer = ''), 800);
293
- const match = this._state.options.findIndex((o) => o.label.toLowerCase().startsWith(this._typeAheadBuffer));
286
+ const match = this._state.model.findIndex((o) => o.label.toLowerCase().startsWith(this._typeAheadBuffer));
294
287
  if (match < 0) return;
295
288
  if (this._popoverEl.open) this._itemButtons[match]?.focus();
296
289
  else this._selectIndex(match, { fromUser: true });
@@ -298,8 +291,11 @@ export class GtkDropDown extends HTMLElement {
298
291
 
299
292
  private _filter(query: string): void {
300
293
  const q = query.trim().toLowerCase();
294
+ // Hoisted for the reason `_applyItemsChanged` gives: this runs on every keystroke of
295
+ // the search field, over every row.
296
+ const model = this._state.model;
301
297
  this._itemButtons.forEach((button, index) => {
302
- const match = q.length === 0 || (this._state.options[index]?.label.toLowerCase().includes(q) ?? false);
298
+ const match = q.length === 0 || (model[index]?.label.toLowerCase().includes(q) ?? false);
303
299
  button.hidden = !match;
304
300
  });
305
301
  }
@@ -348,27 +344,8 @@ export class GtkDropDown extends HTMLElement {
348
344
  if (existing) existing.remove();
349
345
  }
350
346
 
351
- for (const [index, option] of this._state.options.entries()) {
352
- const item = document.createElement('button');
353
- item.type = 'button';
354
- item.className = 'adw-popover-item adw-drop-down-item';
355
- item.setAttribute('role', 'option');
356
- item.tabIndex = -1;
357
-
358
- const label = document.createElement('span');
359
- label.className = 'adw-drop-down-item-label';
360
- label.textContent = option.label;
361
-
362
- const check = document.createElement('span');
363
- check.className = 'adw-drop-down-item-check';
364
- check.setAttribute('aria-hidden', 'true');
365
-
366
- item.append(label, check);
367
- item.addEventListener('click', () => {
368
- this._selectIndex(index, { fromUser: true });
369
- this._popoverEl.popdown();
370
- this._buttonEl.focus();
371
- });
347
+ for (const option of this._state.model) {
348
+ const item = this._createItem(option);
372
349
  this._listEl.appendChild(item);
373
350
  this._itemButtons.push(item);
374
351
  }
@@ -376,6 +353,72 @@ export class GtkDropDown extends HTMLElement {
376
353
  this._updateSelectedStates();
377
354
  if (this._state.count === 0) this._popoverEl.popdown();
378
355
  }
356
+
357
+ /**
358
+ * One row button.
359
+ *
360
+ * The click handler reads the row's CURRENT position out of `_itemButtons` instead of
361
+ * closing over the index it was built at. Under a full rebuild the two were always
362
+ * equal, which is why the closure survived; under a splice they are not, and a row
363
+ * that shifted by one would have selected its neighbour.
364
+ */
365
+ private _createItem(option: GtkDropDownOption): HTMLButtonElement {
366
+ const item = document.createElement('button');
367
+ item.type = 'button';
368
+ item.className = 'adw-popover-item adw-drop-down-item';
369
+ item.setAttribute('role', 'option');
370
+ item.tabIndex = -1;
371
+
372
+ const label = document.createElement('span');
373
+ label.className = 'adw-drop-down-item-label';
374
+ label.textContent = option.label;
375
+
376
+ const check = document.createElement('span');
377
+ check.className = 'adw-drop-down-item-check';
378
+ check.setAttribute('aria-hidden', 'true');
379
+
380
+ item.append(label, check);
381
+ item.addEventListener('click', () => {
382
+ const index = this._itemButtons.indexOf(item);
383
+ if (index < 0) return;
384
+ this._selectIndex(index, { fromUser: true });
385
+ this._popoverEl.popdown();
386
+ this._buttonEl.focus();
387
+ });
388
+ return item;
389
+ }
390
+
391
+ /**
392
+ * Apply one `items-changed` to the row list — remove `removed` buttons at `position`,
393
+ * insert `added` fresh ones there, leave every other row (and its focus) standing.
394
+ *
395
+ * It owns everything a model replacement has to refresh, so the property door and the
396
+ * attribute door share one update path: the button label, the per-row selected state,
397
+ * and popping the popover down when the model empties under it.
398
+ */
399
+ private _applyItemsChanged(change: AdwListItemsChanged): void {
400
+ if (!this._initialized) return;
401
+ for (const removedButton of this._itemButtons.splice(change.position, change.removed)) {
402
+ removedButton.remove();
403
+ }
404
+ const before = this._itemButtons[change.position] ?? null;
405
+ const inserted: HTMLButtonElement[] = [];
406
+ // Read ONCE: `model` hands back a copy (ADR 0046), so reading it per item would make
407
+ // a splice of n items O(n²) instead of the O(n) the splice is.
408
+ const model = this._state.model;
409
+ for (let i = 0; i < change.added; i++) {
410
+ const option = model[change.position + i];
411
+ if (!option) continue;
412
+ const item = this._createItem(option);
413
+ this._listEl.insertBefore(item, before);
414
+ inserted.push(item);
415
+ }
416
+ this._itemButtons.splice(change.position, 0, ...inserted);
417
+
418
+ this._updateSelectedStates();
419
+ this._updateLabel();
420
+ if (this._state.count === 0) this._popoverEl.popdown();
421
+ }
379
422
  }
380
423
 
381
424
  customElements.define('gtk-drop-down', GtkDropDown);
@@ -9,10 +9,13 @@
9
9
  // `normalizeIconName` guard is what keeps a multi-token `icon-name="a b"` from
10
10
  // shipping a stray CSS class — for the button icon and for every menu entry's.
11
11
  //
12
- // The `menu` attribute is a JSON array of `{ "id"?, "label", "icon"? }`. Choosing an
13
- // item closes the popover and fires `menu-item-activated` (CustomEvent, bubbles,
14
- // detail `{ id, label, index }`) with `id` falling back to `label`, matching the NS
15
- // twin.
12
+ // The `menu-model` attribute is a JSON array in the portable menu model (ADR 0042) —
13
+ // items, sections and submenus, with the same attributes GTK reads. Choosing an item
14
+ // closes the popover and fires `menu-item-activated` (CustomEvent, bubbles, detail
15
+ // `{ id, label, path }`) with `id` falling back to `label`, matching the NS twin. The
16
+ // rows themselves are `PopoverMenuView`'s, shared with `<adw-split-button>`: the split
17
+ // button's dropdown half IS a GtkMenuButton, so one popup implementation is the whole
18
+ // point.
16
19
  //
17
20
  // Reference: refs/gtk/gtk/gtkmenubutton.c (GtkMenuButton)
18
21
  // Reference: refs/libadwaita/src/stylesheet/widgets/_buttons.scss (menubutton)
@@ -22,12 +25,18 @@
22
25
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
23
26
 
24
27
  import {
28
+ ADW_MENU_SURFACE_WEB,
29
+ assertMenuRenderable,
25
30
  isSplitButtonDirection,
31
+ menuRefusals,
26
32
  menuButtonPopupDirection,
27
- parseMenuEntries,
28
- stringIsNotEmpty,
33
+ menuItemAt,
34
+ normalizeMenuModel,
35
+ parseMenuModel,
29
36
  } from '@gjsify/adwaita-core';
30
- import type { AdwMenuEntry, SplitButtonDirection } from '@gjsify/adwaita-core';
37
+ import type { AdwMenuActions, AdwMenuInput, AdwMenuModel, SplitButtonDirection } from '@gjsify/adwaita-core';
38
+
39
+ import { PopoverMenuView } from './popover-menu.js';
31
40
 
32
41
  // SIDE-EFFECT import, deliberately separate from the type import below: it guarantees
33
42
  // `gtk-popover` is defined before this module's `customElements.define` can upgrade a
@@ -40,25 +49,6 @@ import type { GtkPopover } from './gtk-popover.js';
40
49
 
41
50
  import { createGtkImage } from './gtk-image.js';
42
51
 
43
- /**
44
- * A menu entry — `@gjsify/adwaita-core`'s {@link AdwMenuEntry} under the name both
45
- * renderers export it as.
46
- *
47
- * IT KEEPS THE `Adw` PREFIX WHILE THE ELEMENT ABOVE IT DOES NOT, and the reason is not
48
- * "the name it has always used" — ADR 0034 clause 1 just moved every name in this file
49
- * that had one. It is that this is not a WIDGET name: clause 1 is about the tag, and
50
- * this is the descriptor a consumer writes menu entries with. `@gjsify/adwaita-
51
- * nativescript` exports `AdwMenuItem` for the same type, and that port deliberately
52
- * keeps `Adw` throughout, so renaming this half would give one object two spellings —
53
- * which is the thing the ADR is removing, not applying. (`GtkDropDownOption` next door
54
- * DID move: its NativeScript twin exports no such name, so there was nothing to split.)
55
- *
56
- * It used to be a SECOND declaration of the same shape, and the NativeScript menu
57
- * button declared a THIRD that was missing `icon` entirely. One type, so a consumer
58
- * writing menu entries writes the same object for either renderer.
59
- */
60
- export type AdwMenuItem = AdwMenuEntry;
61
-
62
52
  /**
63
53
  * Where the popover sits, per `GtkArrowType`. `menuButtonPopupDirection` folds `none`
64
54
  * onto `down` — GtkMenuButton's rule, not ours; this table is only the
@@ -77,13 +67,14 @@ const POPOVER_POSITIONS = {
77
67
  export class GtkMenuButton extends HTMLElement {
78
68
  private _buttonEl!: HTMLButtonElement;
79
69
  private _popoverEl!: GtkPopover;
80
- private _items: AdwMenuItem[] = [];
70
+ private _model: AdwMenuModel = [];
71
+ private _actions: AdwMenuActions | null = null;
81
72
  private _menuTitle = '';
82
73
  private _initialized = false;
83
- private _itemButtons: HTMLButtonElement[] = [];
74
+ private _menuView!: PopoverMenuView;
84
75
 
85
76
  static get observedAttributes() {
86
- return ['icon-name', 'menu-title', 'menu', 'disabled', 'flat', 'circular', 'direction'];
77
+ return ['icon-name', 'menu-title', 'menu-model', 'disabled', 'flat', 'circular', 'direction'];
87
78
  }
88
79
 
89
80
  /** Whether the popover menu is currently open. */
@@ -91,13 +82,69 @@ export class GtkMenuButton extends HTMLElement {
91
82
  return this._popoverEl?.open ?? false;
92
83
  }
93
84
 
94
- /** The menu entries. Setting rebuilds the popover. */
95
- get menuItems(): AdwMenuItem[] {
96
- return this._items;
85
+ /** The menu, normalised (ADR 0042). Setting rebuilds the popover. */
86
+ get menuModel(): AdwMenuModel {
87
+ return this._model;
97
88
  }
98
89
 
99
- set menuItems(value: AdwMenuItem[]) {
100
- this._items = Array.isArray(value) ? value.map((it) => ({ ...it })) : [];
90
+ /**
91
+ * Set the menu from anything the portable model accepts. Refused HERE, at the
92
+ * assignment, for a `custom` item this surface cannot host — see the split button's
93
+ * note on the same setter.
94
+ */
95
+ set menuModel(value: AdwMenuInput) {
96
+ this._model = this._acceptMenu(value);
97
+ if (this._initialized) this._renderMenu();
98
+ }
99
+
100
+ /**
101
+ * Normalise, REFUSE, store — the one door every menu comes through.
102
+ *
103
+ * The property setter is not the only way a menu arrives: an attribute is, and the
104
+ * first cut refused only the setter, so a `custom` item written in markup drew
105
+ * exactly the row `assertMenuRenderable` exists to prevent — one that reads like a
106
+ * command and does nothing.
107
+ *
108
+ * THE TWO DOORS REFUSE DIFFERENTLY, and the difference is which side can hear it.
109
+ * A property assignment is a CALL, so it throws and the caller can catch. An
110
+ * attribute is MARKUP, parsed by the browser: a throw from `connectedCallback` is
111
+ * not delivered to whoever appended the element, it is reported as an uncaught
112
+ * page error — measured, it broke `adwaita-upgrade-order.spec.ts`, which counts
113
+ * exactly those. Nobody can handle it and everybody else pays for it. So the
114
+ * attribute path REFUSES THE MENU (no menu, and a menu-less dropdown is
115
+ * insensitive, which is visible) and says why on `console.error`.
116
+ *
117
+ * A REFUSAL AND A TYPO ARE STILL DIFFERENT THINGS. `parseMenuModel` is total
118
+ * because malformed JSON is an author slip and must not stop the element
119
+ * upgrading; `custom` is well-formed, deliberate, and unhonourable here.
120
+ */
121
+ private _acceptMenu(input: AdwMenuInput, strict = true): AdwMenuModel {
122
+ const model = normalizeMenuModel(input);
123
+ if (strict) {
124
+ assertMenuRenderable(model, ADW_MENU_SURFACE_WEB);
125
+ return model;
126
+ }
127
+ const refusals = menuRefusals(model, ADW_MENU_SURFACE_WEB);
128
+ if (refusals.length === 0) return model;
129
+ // `console.error` is the browser's own channel for "this is wrong, the page
130
+ // continues" — and the alternative here is a row that lies.
131
+ console.error(`${ADW_MENU_SURFACE_WEB.name} cannot render this menu, so it has none:`);
132
+ for (const refusal of refusals) console.error(` [${refusal.path.join('.')}] ${refusal.message}`);
133
+ return [];
134
+ }
135
+
136
+ /**
137
+ * What the action group publishes about the actions this menu names — the portable
138
+ * stand-in for a `GActionGroup`, and the only source of a menu's enabled/checked
139
+ * state (ADR 0042).
140
+ */
141
+ get actions(): AdwMenuActions | null {
142
+ return this._actions;
143
+ }
144
+
145
+ set actions(value: AdwMenuActions | null) {
146
+ this._actions = value ?? null;
147
+ this._menuView?.setActions(this._actions);
101
148
  if (this._initialized) this._renderMenu();
102
149
  }
103
150
 
@@ -145,15 +192,29 @@ export class GtkMenuButton extends HTMLElement {
145
192
  this._popoverEl.anchor = this._buttonEl;
146
193
  this._popoverEl.subscribe((open) => this._onPopoverToggled(open));
147
194
 
195
+ this._menuView = new PopoverMenuView(this._popoverEl, 'adw-menu-button', (path) => {
196
+ const item = menuItemAt(this._model, path);
197
+ if (item === null) return;
198
+ this._popoverEl.popdown();
199
+ this._buttonEl.focus();
200
+ this.dispatchEvent(
201
+ new CustomEvent('menu-item-activated', {
202
+ bubbles: true,
203
+ detail: { id: item.id ?? item.label, label: item.label, path: [...path] },
204
+ }),
205
+ );
206
+ });
207
+ this._menuView.setActions(this._actions);
208
+
148
209
  this._buttonEl.addEventListener('click', () => {
149
210
  if (this.hasAttribute('disabled')) return;
150
211
  // Nothing to show is not a menu — never open an empty popover.
151
- if (!this._popoverEl.open && this._items.length === 0) return;
212
+ if (!this._popoverEl.open && this._model.length === 0) return;
152
213
  this._popoverEl.open = !this._popoverEl.open;
153
214
  });
154
215
 
155
- // Seed items from the `menu` attribute if the property was not set.
156
- if (this._items.length === 0) this._items = this._parseMenuAttr();
216
+ // Seed the menu from the attribute if the property was not set.
217
+ if (this._model.length === 0) this._model = this._parseMenuAttr();
157
218
  // The flat default is set only now the DOM exists, so the observed-attribute
158
219
  // callback (which renders) never runs before `_buttonEl` is created.
159
220
  if (!this.hasAttribute('flat') && !this.hasAttribute('circular')) this.setAttribute('flat', '');
@@ -162,8 +223,8 @@ export class GtkMenuButton extends HTMLElement {
162
223
 
163
224
  attributeChangedCallback(name: string) {
164
225
  if (!this._initialized || !this._buttonEl) return;
165
- if (name === 'menu') {
166
- this._items = this._parseMenuAttr();
226
+ if (name === 'menu-model') {
227
+ this._model = this._parseMenuAttr();
167
228
  this._renderMenu();
168
229
  return;
169
230
  }
@@ -171,21 +232,24 @@ export class GtkMenuButton extends HTMLElement {
171
232
  }
172
233
 
173
234
  /**
174
- * The `menu` attribute, through the core parser the split button already used.
235
+ * The `menu-model` attribute, through the core parser both elements share.
175
236
  *
176
237
  * The copy this replaces was weaker in a way markup can reach: it kept `id` and
177
238
  * `icon` WHATEVER their runtime type, so `{"label":"Open","id":7}` produced an
178
239
  * entry whose `id` is a number in a field typed `string` — and `id` is what the
179
- * activation event reports. The core parser keeps only string-typed extras.
240
+ * activation event reports. The core parser keeps only string-typed attributes.
180
241
  */
181
- private _parseMenuAttr(): AdwMenuItem[] {
182
- return parseMenuEntries(this.getAttribute('menu'));
242
+ private _parseMenuAttr(): AdwMenuModel {
243
+ return this._acceptMenu(parseMenuModel(this.getAttribute('menu-model')), false);
183
244
  }
184
245
 
185
246
  private _onPopoverToggled(open: boolean): void {
186
247
  this.classList.toggle('active', open);
187
248
  this._buttonEl.setAttribute('aria-expanded', String(open));
188
- if (open) this._itemButtons[0]?.focus();
249
+ if (open) this._menuView.focusableRows[0]?.focus();
250
+ // A dismissal returns the popup to its top page: a menu that reopens inside a
251
+ // submenu is one the reader cannot get out of.
252
+ else this._menuView.reset();
189
253
  }
190
254
 
191
255
  private _render(): void {
@@ -207,50 +271,10 @@ export class GtkMenuButton extends HTMLElement {
207
271
  }
208
272
 
209
273
  private _renderMenu(): void {
210
- this._popoverEl.replaceChildren();
211
- this._itemButtons = [];
212
-
213
- const title = this.getAttribute('menu-title') ?? this._menuTitle;
214
- if (title) {
215
- const titleEl = document.createElement('div');
216
- titleEl.className = 'adw-popover-title adw-menu-button-title';
217
- titleEl.textContent = title;
218
- this._popoverEl.appendChild(titleEl);
219
- }
220
-
221
- for (const [index, entry] of this._items.entries()) {
222
- const item = document.createElement('button');
223
- item.type = 'button';
224
- item.className = 'adw-popover-item adw-menu-button-item';
225
- item.setAttribute('role', 'menuitem');
226
- item.tabIndex = -1;
227
-
228
- // An entry that asked for NO icon gets no icon node — the emptiness of the
229
- // declared name, not whether it resolved. Testing `resolvedIconName` dropped
230
- // the node for a name that was given and merely undrawable, which is the one
231
- // case a reader needs to SEE; `<gtk-image>` draws `image-missing` there.
232
- const entryIcon = createGtkImage(entry.icon ?? null, 'adw-menu-button-item-icon');
233
- if (stringIsNotEmpty(entry.icon)) item.appendChild(entryIcon);
234
- const labelEl = document.createElement('span');
235
- labelEl.className = 'adw-menu-button-item-label';
236
- labelEl.textContent = entry.label;
237
- item.appendChild(labelEl);
238
-
239
- item.addEventListener('click', () => {
240
- this._popoverEl.popdown();
241
- this._buttonEl.focus();
242
- this.dispatchEvent(
243
- new CustomEvent('menu-item-activated', {
244
- bubbles: true,
245
- detail: { id: entry.id ?? entry.label, label: entry.label, index },
246
- }),
247
- );
248
- });
249
- this._popoverEl.appendChild(item);
250
- this._itemButtons.push(item);
251
- }
252
-
253
- if (this._items.length === 0) this._popoverEl.popdown();
274
+ this._menuView.setMenuModel(this._model);
275
+ this._menuView.setTitle(this.getAttribute('menu-title') ?? this._menuTitle);
276
+ this._menuView.render();
277
+ if (this._model.length === 0) this._popoverEl.popdown();
254
278
  }
255
279
  }
256
280
 
@@ -139,9 +139,24 @@ export class GtkPopover extends HTMLElement {
139
139
  return ROLES.includes(value as GtkPopoverRole) ? (value as GtkPopoverRole) : 'menu';
140
140
  }
141
141
 
142
- /** The navigable rows, in DOM order. Hidden rows are skipped — a filtered list navigates what it shows. */
142
+ /**
143
+ * The navigable rows, in DOM order.
144
+ *
145
+ * HIDDEN rows are skipped — a filtered list navigates what it shows. DISABLED rows
146
+ * are skipped for a harder reason: a disabled `<button>` cannot take focus at all, so
147
+ * `items[index].focus()` is a NO-OP and the arrow key reads as dead. Keeping one in
148
+ * the walk is the same defect a roving tabindex with no keys behind it has, one row
149
+ * wide — measured on a menu whose action group reported an action disabled
150
+ * (ADR 0042): ArrowDown onto it left `document.activeElement` where it was.
151
+ *
152
+ * GTK skips them for the same reason and says so in the general rule rather than in
153
+ * the menu: `gtk_widget_focus_move` walks only widgets `gtk_widget_get_can_focus`
154
+ * accepts, and an insensitive widget is not one.
155
+ */
143
156
  get items(): HTMLElement[] {
144
- return [...this.querySelectorAll<HTMLElement>(ITEM_SELECTOR)].filter((item) => !item.hidden);
157
+ return [...this.querySelectorAll<HTMLElement>(ITEM_SELECTOR)].filter(
158
+ (item) => !item.hidden && !(item as HTMLButtonElement).disabled,
159
+ );
145
160
  }
146
161
 
147
162
  /** Show the popover — `gtk_popover_popup()`. */