@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,19 +1,62 @@
1
- // <adw-combo-row> — row with a title/subtitle and a dropdown select. `items` is a JSON
2
- // `string[]`, `selected` an index, and the native <select> is stretched invisibly over the
3
- // row so clicking anywhere opens it.
1
+ // <adw-combo-row> — row with a title/subtitle and a dropdown select. The model is a JSON
2
+ // array in the `model` attribute (`["a","b"]` or `[{"value":"a","label":"A"}]`), `selected`
3
+ // an index, and the native <select> is stretched invisibly over the row so clicking
4
+ // anywhere opens it.
4
5
  //
5
- // The SELECTION state machine (the options list, the two-way index↔value mapping, the
6
+ // The SELECTION state machine (the model, the two-way index↔value mapping, the
6
7
  // empty/out-of-range guards and the programmatic-vs-interactive notify split) is HEADLESS
7
8
  // and lives in `@gjsify/adwaita-core` (ADR 0004) as {@link ComboState}; this element keeps
8
9
  // only the DOM half — the <select>, the inline value label and `notify::selected`.
9
10
  //
11
+ // THE MODEL IS INPUT, AND INPUT STAYS LIVE. This row and `<gtk-drop-down>` are the same
12
+ // list widget on GTK and share that one `ComboState` here — but the row used to parse its
13
+ // items once in `connectedCallback`, observe only `['title','subtitle','selected']` and
14
+ // publish no accessor at all, so a model replaced after connect changed nothing, silently
15
+ // (#1525). Both spellings now reach the same rebuild, and
16
+ // `scripts/check-adwaita-collection-reactivity.mjs` holds the rule for every collection
17
+ // either renderer takes in.
18
+ //
19
+ // THE MODEL IS SPLICED, NOT REBUILT (ADR 0046). `ComboState` reports WHERE the model
20
+ // changed — `Gio.ListModel::items-changed`, one splice per assignment — and this element
21
+ // applies exactly that range. Appending one item to a hundred-item model adds ONE
22
+ // `<option>` and leaves the other hundred nodes standing; the previous code dropped every
23
+ // node on every assignment, which is also what threw away the browser's own state for the
24
+ // rows that did not change. It is what removes the ordering hazard this file used to
25
+ // carry as a comment: the splice arrives BEFORE the selection change, so the subscriber's
26
+ // index write already lands on the new list.
27
+ //
28
+ // Attributes:
29
+ // title / subtitle — the text column.
30
+ // model — JSON array: `["a","b"]` or `[{"value":"a","label":"A"}]`.
31
+ // selected — the selected index (number).
32
+ // Properties — the `<gtk-drop-down>` set minus `enableSearch` and `active`, which are that
33
+ // element's own popover chrome and have no counterpart on a row that opens a native
34
+ // <select>:
35
+ // model — the list model ({ value, label }[]) (get/set). `Adw.ComboRow:model`'s
36
+ // own name, per ADR 0034 clause 1; it replaced `options`/`items`, which were two
37
+ // further spellings of one GTK property.
38
+ // selected — the selected index (get/set). PERMISSIVE, and this is the one row of
39
+ // `ComboState`'s contract the two selectors answer differently: an index past the end
40
+ // is ACCEPTED here and reads back with an empty `selectedValue`/label, where
41
+ // `<gtk-drop-down>` rejects the set outright (gtk-drop-down.ts). Neither is a bug —
42
+ // `ComboState.hasIndex` is where the bounds live and why the policy is each
43
+ // renderer's. Stated here because a consumer of THIS element reads this file.
44
+ // selectedValue — the selected option's value ('' when empty) (get/set-by-value).
45
+ // selectedItem — the selected option descriptor, or null (get).
46
+ // Events:
47
+ // `notify::selected` (CustomEvent, bubbles, detail = { selected }) — a USER pick.
48
+ // Deliberately NOT every change, unlike `<gtk-drop-down>`: this row's event has
49
+ // always been the interactive half of `ComboState`'s split, and `adw-row-state.spec.ts`
50
+ // pins it.
51
+ //
10
52
  // Adapted from Adwaita Web UI Framework (https://github.com/mclellac/adwaita-web).
11
53
  // Copyright (c) 2025 csm. MIT License.
12
54
  // Modifications: Reimplemented as Web Component for @gjsify/adwaita-web;
13
55
  // title/subtitle text column added to match Adw.ComboRow; the selection state
14
56
  // machine composed from @gjsify/adwaita-core.
15
57
 
16
- import { ComboState, deriveRowLabels, normalizeComboOptions } from '@gjsify/adwaita-core';
58
+ import { ComboState, deriveRowLabels, normalizeComboOptions, parseListModel } from '@gjsify/adwaita-core';
59
+ import type { AdwComboOption, AdwListItemsChanged, AdwListModelInput } from '@gjsify/adwaita-core';
17
60
 
18
61
  export class AdwComboRow extends HTMLElement {
19
62
  private _select!: HTMLSelectElement;
@@ -25,9 +68,27 @@ export class AdwComboRow extends HTMLElement {
25
68
  private _initialized = false;
26
69
 
27
70
  static get observedAttributes() {
28
- return ['title', 'subtitle', 'selected'];
71
+ return ['title', 'subtitle', 'model', 'selected'];
29
72
  }
30
73
 
74
+ /** The list model (`Adw.ComboRow:model`). Setting it splices the <select> and clamps the selection. */
75
+ get model(): AdwComboOption[] {
76
+ return this._state.model;
77
+ }
78
+
79
+ set model(value: AdwListModelInput) {
80
+ // `setModel` re-runs autoselect, so an index the new model does not have falls
81
+ // back to 0 — the clamp is core's, exactly as `<gtk-drop-down>` gets it.
82
+ this._state.setModel(normalizeComboOptions(value));
83
+ }
84
+
85
+ /**
86
+ * The selected index (Adw.ComboRow:selected). PERMISSIVE: an index past the end is
87
+ * accepted and reads back with an empty {@link selectedValue}, mirroring the `guint`
88
+ * property that takes any position — where `<gtk-drop-down>` rejects the same set, as
89
+ * its own docs promise. `ComboState.hasIndex` owns the bounds and records why the
90
+ * POLICY is each renderer's; `COMBO_SELECTION_VECTORS` drives both answers.
91
+ */
31
92
  get selected(): number {
32
93
  return this._initialized ? this._state.selectedIndex : parseInt(this.getAttribute('selected') || '0', 10);
33
94
  }
@@ -36,17 +97,47 @@ export class AdwComboRow extends HTMLElement {
36
97
  this.setAttribute('selected', String(value));
37
98
  }
38
99
 
100
+ /**
101
+ * The selected option's `value`, or '' when nothing is selected (Adw.ComboRow:selected-item).
102
+ *
103
+ * Assigning routes through `selected`, i.e. through the attribute, so a set BY VALUE
104
+ * and a set BY INDEX leave the element in the same observable state — including the
105
+ * reflected `selected=` a consumer or a stylesheet may be reading. An unknown value is
106
+ * a no-op, as on `<gtk-drop-down>`.
107
+ */
108
+ get selectedValue(): string {
109
+ return this._state.selectedValue;
110
+ }
111
+
112
+ set selectedValue(value: string) {
113
+ const index = this._state.model.findIndex((option) => option.value === value);
114
+ if (index >= 0) this.selected = index;
115
+ }
116
+
117
+ /**
118
+ * The selected option descriptor, or `null` (Adw.ComboRow:selected-item).
119
+ *
120
+ * Read-only, as the GObject property is. `null` and not a blank descriptor: the
121
+ * sentinel and an index past the end both address no option, and inventing an empty
122
+ * one would make "nothing is selected" indistinguishable from an option with no text.
123
+ */
124
+ get selectedItem(): AdwComboOption | null {
125
+ return this._state.model[this._state.selectedIndex] ?? null;
126
+ }
127
+
39
128
  connectedCallback() {
40
129
  if (this._initialized) return;
41
130
  this._initialized = true;
42
131
 
43
- const items: string[] = JSON.parse(this.getAttribute('items') || '[]');
44
132
  // Seed the headless state BEFORE subscribing, so building the initial DOM below is
45
133
  // not driven by a change notification. The string→descriptor mapping is core's, so
46
134
  // this row and `<gtk-drop-down>` accept one option vocabulary.
47
- this._state.setOptions(normalizeComboOptions(items));
135
+ //
136
+ // Only when the PROPERTY was not already set, the rule `<adw-data-grid>` and
137
+ // `<gtk-drop-down>` both follow: a model assigned to a detached element must
138
+ // survive being attached, and an absent attribute must not blank it.
139
+ if (this._state.count === 0) this._state.setModel(parseListModel(this.getAttribute('model')));
48
140
  this._state.setSelectedIndex(parseInt(this.getAttribute('selected') || '0', 10));
49
- const selectedIdx = this._state.selectedIndex;
50
141
 
51
142
  const text = document.createElement('div');
52
143
  text.className = 'adw-row-text';
@@ -58,20 +149,14 @@ export class AdwComboRow extends HTMLElement {
58
149
 
59
150
  this._valueEl = document.createElement('span');
60
151
  this._valueEl.className = 'adw-row-value';
61
- this._valueEl.textContent = this._state.selectedLabel;
62
152
 
63
- const select = document.createElement('select');
64
- items.forEach((item, i) => {
65
- const option = document.createElement('option');
66
- option.value = String(i);
67
- option.textContent = item;
68
- if (i === selectedIdx) option.selected = true;
69
- select.appendChild(option);
70
- });
153
+ this._select = document.createElement('select');
71
154
 
72
- this.replaceChildren(text, this._valueEl, select);
155
+ this.replaceChildren(text, this._valueEl, this._select);
73
156
 
74
- this._select = select;
157
+ // WHERE the model changed — applied before the selection change below, which is
158
+ // the ordering that lets that subscriber's index write land on the new list.
159
+ this._state.subscribeItems((change) => this._applyItemsChanged(change));
75
160
 
76
161
  // The core state drives the inline label + the <select> on every change,
77
162
  // and the notify::selected event only on an interactive pick.
@@ -95,13 +180,15 @@ export class AdwComboRow extends HTMLElement {
95
180
  this._state.select(this._select.selectedIndex);
96
181
  });
97
182
 
98
- this._syncChooser();
183
+ this._renderOptions();
99
184
  this._renderText();
100
185
  }
101
186
 
102
187
  attributeChangedCallback(name: string, _old: string | null, value: string | null) {
103
188
  if (!this._initialized) return;
104
- if (name === 'selected') {
189
+ if (name === 'model') {
190
+ this._state.setModel(parseListModel(value));
191
+ } else if (name === 'selected') {
105
192
  // A programmatic set — the core state notifies `interactive: false`,
106
193
  // so the subscriber re-syncs the display without emitting an event.
107
194
  this._state.setSelectedIndex(parseInt(value || '0', 10));
@@ -110,6 +197,58 @@ export class AdwComboRow extends HTMLElement {
110
197
  }
111
198
  }
112
199
 
200
+ /**
201
+ * Build one `<option>` for a model item.
202
+ *
203
+ * ITS `value` IS THE MODEL'S value, not the position. The position used to be written
204
+ * there with a comment defending it, and nothing in this package or its consumers ever
205
+ * read it back — while it made every insertion renumber the whole tail, which is the
206
+ * one thing a splice exists to avoid. The element still addresses its model by
207
+ * POSITION (`selectedIndex`); what the attribute now carries is the descriptor's own
208
+ * value, which is also what a form submission would have wanted.
209
+ */
210
+ private _createOption(option: AdwComboOption): HTMLOptionElement {
211
+ const el = document.createElement('option');
212
+ el.value = option.value;
213
+ el.textContent = option.label;
214
+ return el;
215
+ }
216
+
217
+ /**
218
+ * Apply one `items-changed` to the `<option>` list — remove `removed` nodes at
219
+ * `position`, insert `added` fresh ones there, touch nothing else.
220
+ *
221
+ * A no-op before `connectedCallback` has built the DOM, which is what lets `model` be
222
+ * assigned to a detached element: the state keeps the model and the connect path
223
+ * renders it whole.
224
+ */
225
+ private _applyItemsChanged(change: AdwListItemsChanged): void {
226
+ if (!this._initialized) return;
227
+ for (let i = 0; i < change.removed; i++) this._select.remove(change.position);
228
+ const before = this._select.options[change.position] ?? null;
229
+ // Read ONCE: `model` hands back a copy (ADR 0046), so reading it per item would make
230
+ // a splice of n items O(n²) instead of the O(n) the splice is.
231
+ const model = this._state.model;
232
+ for (let i = 0; i < change.added; i++) {
233
+ const option = model[change.position + i];
234
+ if (option) this._select.insertBefore(this._createOption(option), before);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Build the whole `<option>` list — the CONNECT path only, where there is no previous
240
+ * list to splice against.
241
+ *
242
+ * Every later assignment goes through {@link _applyItemsChanged}.
243
+ */
244
+ private _renderOptions(): void {
245
+ if (!this._initialized) return;
246
+ this._select.replaceChildren(...this._state.model.map((option) => this._createOption(option)));
247
+ this._select.selectedIndex = this._state.selectedIndex;
248
+ this._valueEl.textContent = this._state.selectedLabel;
249
+ this._syncChooser();
250
+ }
251
+
113
252
  /**
114
253
  * `model_changed`: one option or none is not a choice, so the arrow goes and the row
115
254
  * stops being activatable.
@@ -1,29 +1,40 @@
1
1
  // <adw-spin-row> — row with a title/subtitle and a numeric spin control (+/− buttons).
2
2
  //
3
- // The ADJUSTMENT state machine (value/min/max/step plus the clamp-on-every-mutation and
4
- // the change detection) is HEADLESS and lives in `@gjsify/adwaita-core` (ADR 0004) as
3
+ // The ADJUSTMENT state machine (the range, the clamp on every mutation and the change
4
+ // detection) is HEADLESS and lives in `@gjsify/adwaita-core` (ADR 0004) as
5
5
  // {@link SpinState}; this element keeps only the DOM half — the −/input/+ control, the
6
6
  // step-precision display (the `Adw.SpinRow:digits` concern, which belongs to the widget
7
7
  // rather than to the adjustment) and the `notify::value` event.
8
8
  //
9
+ // THE RANGE IS ONE ATTRIBUTE, `adjustment`, and it is JSON:
10
+ //
11
+ // <adw-spin-row title="Zoom" adjustment='{"lower":1,"upper":20,"stepIncrement":1}'>
12
+ //
13
+ // `min` / `max` / `step` are gone. `Adw.SpinRow` has no such properties — it has an
14
+ // `adjustment`, and three renderers spelling that range three ways is what ADR 0047
15
+ // removes. The value is the portable `AdwAdjustment`, so a range authored here is the one
16
+ // the NativeScript row and the React Native widget take. The same markup door
17
+ // `<adw-combo-row model=…>` opens for its list, on the same reasoning.
18
+ //
9
19
  // Adapted from Adwaita Web UI Framework (https://github.com/mclellac/adwaita-web).
10
20
  // Copyright (c) 2025 csm. MIT License.
11
21
  // Modifications: Reimplemented as Web Component for @gjsify/adwaita-web;
12
22
  // title/subtitle text column added to match Adw.SpinRow; the adjustment state
13
23
  // machine composed from @gjsify/adwaita-core.
14
24
 
15
- import { SpinState, deriveRowLabels } from '@gjsify/adwaita-core';
25
+ import { SpinState, deriveRowLabels, normalizeAdjustment, parseAdjustment } from '@gjsify/adwaita-core';
26
+ import type { AdwAdjustment, AdwAdjustmentInput } from '@gjsify/adwaita-core';
16
27
 
17
28
  export class AdwSpinRow extends HTMLElement {
18
29
  private _input!: HTMLInputElement;
19
30
  private _titleEl!: HTMLSpanElement;
20
31
  private _subtitleEl!: HTMLSpanElement;
21
- /** The headless value/min/max/step adjustment with clamping (ADR 0004). */
32
+ /** The headless adjustment: the range, the clamp and the two signals (ADR 0004, ADR 0047). */
22
33
  private readonly _state = new SpinState();
23
34
  private _initialized = false;
24
35
 
25
36
  static get observedAttributes() {
26
- return ['title', 'subtitle', 'value', 'min', 'max', 'step'];
37
+ return ['title', 'subtitle', 'value', 'adjustment'];
27
38
  }
28
39
 
29
40
  get value(): number {
@@ -37,16 +48,35 @@ export class AdwSpinRow extends HTMLElement {
37
48
  this._syncValue();
38
49
  }
39
50
 
51
+ /**
52
+ * The numeric range — `Adw.SpinRow:adjustment`, as the portable value.
53
+ *
54
+ * Reads back whole; writes MERGE, so `el.adjustment = { upper: 20 }` moves one bound
55
+ * and leaves the value where it is. A string is read as the JSON the attribute carries,
56
+ * so the property and the attribute mean the same thing.
57
+ */
58
+ get adjustment(): AdwAdjustment {
59
+ return this._state.adjustment;
60
+ }
61
+
62
+ set adjustment(v: AdwAdjustmentInput | string) {
63
+ this._state.configure(typeof v === 'string' ? parseAdjustment(v) : v);
64
+ }
65
+
40
66
  connectedCallback() {
41
67
  if (this._initialized) return;
42
68
  this._initialized = true;
43
69
 
44
70
  // Seed the headless adjustment BEFORE subscribing, so building the initial
45
- // DOM below is not driven by a change notification.
46
- this._state.setMin(parseFloat(this.getAttribute('min') || '0'));
47
- this._state.setMax(parseFloat(this.getAttribute('max') || '100'));
48
- this._state.setStep(parseFloat(this.getAttribute('step') || '1'));
49
- this._state.setValue(this._roundToStep(parseFloat(this.getAttribute('value') || String(this._state.min))));
71
+ // DOM below is not driven by a change notification. The range comes whole, so the
72
+ // authored `value` attribute is applied against the range it was authored beside
73
+ // rather than against whatever a partly-seeded state held.
74
+ const range = normalizeAdjustment(parseAdjustment(this.getAttribute('adjustment')));
75
+ this._state.configure(range);
76
+ const authoredValue = this.getAttribute('value');
77
+ this._state.setValue(
78
+ this._roundToStep(authoredValue === null ? range.value : Number.parseFloat(authoredValue)),
79
+ );
50
80
 
51
81
  const text = document.createElement('div');
52
82
  text.className = 'adw-row-text';
@@ -62,7 +92,7 @@ export class AdwSpinRow extends HTMLElement {
62
92
  const decBtn = document.createElement('button');
63
93
  decBtn.className = 'adw-spin-dec';
64
94
  decBtn.textContent = '−';
65
- decBtn.addEventListener('click', () => this._adjust(-this._state.step));
95
+ decBtn.addEventListener('click', () => this._adjust(-this._state.adjustment.stepIncrement));
66
96
 
67
97
  const input = document.createElement('input');
68
98
  input.type = 'text';
@@ -80,7 +110,7 @@ export class AdwSpinRow extends HTMLElement {
80
110
  const incBtn = document.createElement('button');
81
111
  incBtn.className = 'adw-spin-inc';
82
112
  incBtn.textContent = '+';
83
- incBtn.addEventListener('click', () => this._adjust(this._state.step));
113
+ incBtn.addEventListener('click', () => this._adjust(this._state.adjustment.stepIncrement));
84
114
 
85
115
  control.append(decBtn, input, incBtn);
86
116
  this.replaceChildren(text, control);
@@ -99,21 +129,12 @@ export class AdwSpinRow extends HTMLElement {
99
129
  this._renderText();
100
130
  return;
101
131
  }
102
- const num = parseFloat(val || '0');
103
- switch (name) {
104
- case 'value':
105
- this._state.setValue(this._roundToStep(num));
106
- break;
107
- case 'min':
108
- this._state.setMin(num);
109
- break;
110
- case 'max':
111
- this._state.setMax(num);
112
- break;
113
- case 'step':
114
- this._state.setStep(num);
115
- break;
132
+ if (name === 'adjustment') {
133
+ this._state.configure(parseAdjustment(val));
134
+ return;
116
135
  }
136
+ // `value`, the only other observed attribute reaching here.
137
+ this._state.setValue(this._roundToStep(Number.parseFloat(val || '0')));
117
138
  }
118
139
 
119
140
  private _renderText() {
@@ -151,7 +172,7 @@ export class AdwSpinRow extends HTMLElement {
151
172
 
152
173
  private _roundToStep(n: number): number {
153
174
  if (!Number.isFinite(n)) return n;
154
- const decimals = this._countDecimals(this._state.step);
175
+ const decimals = this._countDecimals(this._state.adjustment.stepIncrement);
155
176
  return decimals > 0 ? parseFloat(n.toFixed(decimals)) : n;
156
177
  }
157
178
 
@@ -162,7 +183,7 @@ export class AdwSpinRow extends HTMLElement {
162
183
  }
163
184
 
164
185
  private _formatValue(v: number): string {
165
- const decimals = this._countDecimals(this._state.step);
186
+ const decimals = this._countDecimals(this._state.adjustment.stepIncrement);
166
187
  return decimals > 0 ? v.toFixed(decimals) : String(v);
167
188
  }
168
189
  }
@@ -11,14 +11,15 @@
11
11
  // tooltip — tooltip/accessible name of the action half.
12
12
  // dropdown-tooltip — tooltip of the dropdown half; empty restores "More Options".
13
13
  // direction — none | up | down | left | right (default down).
14
- // menu — JSON array of entries, each `{ "label", "action"? }`.
14
+ // menu-model — JSON array in the portable menu model (ADR 0042): items,
15
+ // sections (`{"section":[…]}`) and submenus (`{"submenu":[…]}`).
15
16
  // disabled, flat, suggested, destructive — boolean flags.
16
17
  // Events (all CustomEvent, bubbles — mirror the Adw.SplitButton GObject signals):
17
18
  // `clicked` — the primary action half was activated.
18
19
  // `notify::active` — the dropdown menu was opened/closed (detail.active).
19
20
  // `notify::<prop>` — one per GObject property the state machine notified,
20
21
  // in libadwaita's emission order.
21
- // `menu-activated` — a menu entry was chosen (detail.label / .action / .index).
22
+ // `menu-activated` — a menu item was chosen (detail.label / .action / .id / .path).
22
23
  //
23
24
  // All of the behaviour above lives in `@gjsify/adwaita-core` (ADR 0004) as
24
25
  // {@link SplitButtonState}; this element only paints it and translates DOM events
@@ -36,15 +37,28 @@
36
37
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
37
38
 
38
39
  import {
40
+ ADW_MENU_SURFACE_WEB,
39
41
  SplitButtonState,
42
+ assertMenuRenderable,
40
43
  isSplitButtonDirection,
41
- parseMenuEntries,
44
+ menuRefusals,
45
+ normalizeMenuModel,
46
+ parseMenuModel,
42
47
  resolveDropdownTooltip,
43
48
  splitButtonArrowIcon,
44
49
  splitButtonPopupDirection,
45
50
  splitButtonRootState,
46
51
  } from '@gjsify/adwaita-core';
47
- import type { AdwArrowIcon, AdwMenuEntry, SplitButtonChange, SplitButtonDirection } from '@gjsify/adwaita-core';
52
+ import type {
53
+ AdwArrowIcon,
54
+ AdwMenuActions,
55
+ AdwMenuInput,
56
+ AdwMenuModel,
57
+ SplitButtonChange,
58
+ SplitButtonDirection,
59
+ } from '@gjsify/adwaita-core';
60
+
61
+ import { PopoverMenuView } from './popover-menu.js';
48
62
 
49
63
  // SIDE-EFFECT import, deliberately separate from the type import below: it is
50
64
  // what guarantees `gtk-popover` is defined before this module's own
@@ -109,6 +123,8 @@ export class AdwSplitButton extends HTMLElement {
109
123
  private _dropdownEl!: HTMLButtonElement;
110
124
  private _arrowEl!: GtkImage;
111
125
  private _menuEl!: GtkPopover;
126
+ private _menuView!: PopoverMenuView;
127
+ private _actions: AdwMenuActions | null = null;
112
128
  /** Inline text content, captured before we take over the subtree. */
113
129
  private _inlineLabel = '';
114
130
  /** Last `open` value reflected as `notify::active`, so the event fires once per flip. */
@@ -123,7 +139,7 @@ export class AdwSplitButton extends HTMLElement {
123
139
  'tooltip',
124
140
  'dropdown-tooltip',
125
141
  'direction',
126
- 'menu',
142
+ 'menu-model',
127
143
  'disabled',
128
144
  'flat',
129
145
  'suggested',
@@ -162,12 +178,74 @@ export class AdwSplitButton extends HTMLElement {
162
178
  this.removeAttribute('label');
163
179
  }
164
180
 
165
- get menuItems(): readonly AdwMenuEntry[] {
181
+ /** The menu, normalised (ADR 0042). Empty when the dropdown has none. */
182
+ get menuModel(): AdwMenuModel {
166
183
  return this._state.menuModel ?? [];
167
184
  }
168
185
 
169
- set menuItems(value: readonly AdwMenuEntry[]) {
170
- this._state.setMenuModel(Array.isArray(value) ? value.map((entry) => ({ ...entry })) : null);
186
+ /**
187
+ * Set the menu from anything the portable model accepts — a bare `string[]`, item
188
+ * descriptors, sections, submenus.
189
+ *
190
+ * The REFUSAL is here rather than at render time: a `custom` item names an
191
+ * application widget this surface cannot host, and discovering that as a blank row
192
+ * three interactions later is what {@link assertMenuRenderable} exists to prevent.
193
+ */
194
+ set menuModel(value: AdwMenuInput) {
195
+ // Refused BEFORE it is stored, so a throw leaves the widget showing the menu it
196
+ // had rather than one it cannot draw.
197
+ this._state.setMenuModel(this._acceptMenu(value));
198
+ if (this._initialized) this._render();
199
+ }
200
+
201
+ /**
202
+ * Normalise, REFUSE, store — the one door every menu comes through.
203
+ *
204
+ * The property setter is not the only way a menu arrives: an attribute is, and the
205
+ * first cut refused only the setter, so a `custom` item written in markup drew
206
+ * exactly the row `assertMenuRenderable` exists to prevent — one that reads like a
207
+ * command and does nothing.
208
+ *
209
+ * THE TWO DOORS REFUSE DIFFERENTLY, and the difference is which side can hear it.
210
+ * A property assignment is a CALL, so it throws and the caller can catch. An
211
+ * attribute is MARKUP, parsed by the browser: a throw from `connectedCallback` is
212
+ * not delivered to whoever appended the element, it is reported as an uncaught
213
+ * page error — measured, it broke `adwaita-upgrade-order.spec.ts`, which counts
214
+ * exactly those. Nobody can handle it and everybody else pays for it. So the
215
+ * attribute path REFUSES THE MENU (no menu, and a menu-less dropdown is
216
+ * insensitive, which is visible) and says why on `console.error`.
217
+ *
218
+ * A REFUSAL AND A TYPO ARE STILL DIFFERENT THINGS. `parseMenuModel` is total
219
+ * because malformed JSON is an author slip and must not stop the element
220
+ * upgrading; `custom` is well-formed, deliberate, and unhonourable here.
221
+ */
222
+ private _acceptMenu(input: AdwMenuInput, strict = true): AdwMenuModel {
223
+ const model = normalizeMenuModel(input);
224
+ if (strict) {
225
+ assertMenuRenderable(model, ADW_MENU_SURFACE_WEB);
226
+ return model;
227
+ }
228
+ const refusals = menuRefusals(model, ADW_MENU_SURFACE_WEB);
229
+ if (refusals.length === 0) return model;
230
+ // `console.error` is the browser's own channel for "this is wrong, the page
231
+ // continues" — and the alternative here is a row that lies.
232
+ console.error(`${ADW_MENU_SURFACE_WEB.name} cannot render this menu, so it has none:`);
233
+ for (const refusal of refusals) console.error(` [${refusal.path.join('.')}] ${refusal.message}`);
234
+ return [];
235
+ }
236
+
237
+ /**
238
+ * What the action group publishes about the actions this menu names — the portable
239
+ * stand-in for a `GActionGroup`, and the ONLY place a menu's enabled/checked state
240
+ * can come from (ADR 0042). Setting it re-renders the rows.
241
+ */
242
+ get actions(): AdwMenuActions | null {
243
+ return this._actions;
244
+ }
245
+
246
+ set actions(value: AdwMenuActions | null) {
247
+ this._actions = value ?? null;
248
+ this._menuView?.setActions(this._actions);
171
249
  if (this._initialized) this._render();
172
250
  }
173
251
 
@@ -248,8 +326,29 @@ export class AdwSplitButton extends HTMLElement {
248
326
  // `notify::active` all read it). A dismissal the popover owns — outside
249
327
  // click, Escape — is fed back in here rather than diverging.
250
328
  this._menuEl.subscribe((open) => {
251
- if (!open) this._state.closeMenu();
329
+ if (!open) {
330
+ this._state.closeMenu();
331
+ // A menu that reopens three pages deep is a menu the reader cannot get
332
+ // out of, so a dismissal returns it to the top.
333
+ this._menuView.reset();
334
+ }
335
+ });
336
+ this._menuView = new PopoverMenuView(this._menuEl, 'adw-split-button-menu', (path) => {
337
+ const activated = this._state.activateMenuItem(path);
338
+ if (activated === null) return;
339
+ this.dispatchEvent(
340
+ new CustomEvent('menu-activated', {
341
+ bubbles: true,
342
+ detail: {
343
+ label: activated.label,
344
+ action: activated.action,
345
+ id: activated.id ?? activated.label,
346
+ path: [...path],
347
+ },
348
+ }),
349
+ );
252
350
  });
351
+ this._menuView.setActions(this._actions);
253
352
 
254
353
  this._actionEl.addEventListener('click', () => {
255
354
  if (this.hasAttribute('disabled')) return;
@@ -284,8 +383,8 @@ export class AdwSplitButton extends HTMLElement {
284
383
  case 'dropdown-tooltip':
285
384
  this._state.setDropdownTooltip(value ?? '');
286
385
  break;
287
- case 'menu':
288
- this._state.setMenuModel(parseMenuEntries(value));
386
+ case 'menu-model':
387
+ this._state.setMenuModel(this._acceptMenu(parseMenuModel(value), false));
289
388
  break;
290
389
  default:
291
390
  break;
@@ -307,9 +406,11 @@ export class AdwSplitButton extends HTMLElement {
307
406
  const direction = this.getAttribute('direction');
308
407
  if (isSplitButtonDirection(direction)) this._state.setDirection(direction);
309
408
  this._state.setDropdownTooltip(this.getAttribute('dropdown-tooltip') ?? '');
310
- // The `menuItems` property may have been set before the element upgraded;
409
+ // The `menuModel` property may have been set before the element upgraded;
311
410
  // the attribute only seeds an otherwise empty menu.
312
- if (this._state.menuModel === null) this._state.setMenuModel(parseMenuEntries(this.getAttribute('menu')));
411
+ if (this._state.menuModel === null) {
412
+ this._state.setMenuModel(this._acceptMenu(parseMenuModel(this.getAttribute('menu-model')), false));
413
+ }
313
414
  }
314
415
 
315
416
  /**
@@ -344,7 +445,7 @@ export class AdwSplitButton extends HTMLElement {
344
445
  this._render();
345
446
  // Focus lands in the menu on open, AFTER _render has built the rows —
346
447
  // otherwise the popover's arrow keys have nothing to move from.
347
- if (opened) this._menuEl.items[0]?.focus();
448
+ if (opened) this._menuView.focusableRows[0]?.focus();
348
449
  }
349
450
 
350
451
  private _render(): void {
@@ -423,32 +524,9 @@ export class AdwSplitButton extends HTMLElement {
423
524
  }
424
525
 
425
526
  private _renderMenu(): void {
426
- this._menuEl.replaceChildren();
427
527
  this._menuEl.open = this._state.open;
428
-
429
- for (const [index, entry] of (this._state.menuModel ?? []).entries()) {
430
- const item = document.createElement('button');
431
- item.type = 'button';
432
- // `.adw-popover-item` is what makes the row navigable: it is the selector
433
- // `<gtk-popover>` walks for arrow/Home/End/Enter.
434
- item.className = 'adw-popover-item adw-split-button-menu-item';
435
- item.setAttribute('role', 'menuitem');
436
- item.tabIndex = -1;
437
- item.textContent = entry.label;
438
- item.addEventListener('click', () => {
439
- // By position, never by label: two entries called "Copy" are legal
440
- // and must each dispatch their own action.
441
- const activated = this._state.activateMenuEntry(index);
442
- if (activated === null) return;
443
- this.dispatchEvent(
444
- new CustomEvent('menu-activated', {
445
- bubbles: true,
446
- detail: { label: activated.label, action: activated.action, index },
447
- }),
448
- );
449
- });
450
- this._menuEl.appendChild(item);
451
- }
528
+ this._menuView.setMenuModel(this._state.menuModel ?? []);
529
+ this._menuView.render();
452
530
  }
453
531
  }
454
532