@gjsify/adwaita-web 0.46.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.
@@ -0,0 +1,366 @@
1
+ // The menu INSIDE a `<gtk-popover>` — one implementation for both menu-bearing
2
+ // elements (ADR 0042).
3
+ //
4
+ // `<adw-split-button>` and `<gtk-menu-button>` are different buttons with the same
5
+ // popup: `GtkMenuButton` builds it in both cases, and the split button's dropdown half
6
+ // IS one. Before the portable menu model each element built its own rows, and the two
7
+ // disagreed about everything a model can carry — one drew icons and an `id`, the other
8
+ // drew neither; neither drew a section, a submenu, an accelerator, or a check.
9
+ //
10
+ // WHAT THIS DRAWS, which is what `GtkPopoverMenu` draws:
11
+ //
12
+ // · a SECTION is inlined with a separator on each boundary (never a leading one) and
13
+ // an optional heading — the split `flattenMenu` decides, not this file;
14
+ // · a SUBMENU is a row that SWAPS THE PAGE, with a back row above the new list. That
15
+ // is `GtkPopoverMenu`'s own shape (it is a stack of pages with a back button), and
16
+ // it is what keeps "the user opened it" true — inlining a submenu would offer items
17
+ // nobody asked for;
18
+ // · CHECK and RADIO decorations, and the dim of an insensitive row, from
19
+ // `resolveMenuItemState` — never from a field on the item, because `GMenuModel` has
20
+ // no such field;
21
+ // · an ACCELERATOR, right-aligned and dimmed (`popover.menu accelerator`). It is
22
+ // DISPLAY ONLY: nothing here binds a key, exactly as in GTK, where the binding
23
+ // comes from `gtk_application_set_accels_for_action`.
24
+ //
25
+ // THE KEYS ARE SPLIT, and the split is the reason this file registers a listener at all.
26
+ // `<gtk-popover>` owns the LIST keys for every surface that has rows — Arrow up/down with
27
+ // wrap, Home/End, Enter/Space to activate, Escape to dismiss — walking `.adw-popover-item`
28
+ // and skipping what cannot take focus. Re-implementing those here would be a second copy
29
+ // of one arithmetic on the same rows, and both copies would move the focus.
30
+ //
31
+ // What a popover cannot own is what makes a MENU a menu: LEFT and RIGHT change the PAGE.
32
+ // GTK puts that rule on the row rather than on the surface too —
33
+ // `gtk_model_button_focus` (`refs/gtk/gtk/gtkmodelbutton.c:1174-1210`): a focused NORMAL
34
+ // row with a `menu-name` takes GTK_DIR_RIGHT into that submenu (`:1189-1195`), and a
35
+ // focused TITLE row — the back row at the top of an open page — takes GTK_DIR_LEFT out of
36
+ // it (`:1182-1188`). Both are the row's own answer to a direction, which is why Left from
37
+ // the middle of a submenu does nothing in GTK and does nothing here.
38
+ //
39
+ // ARIA IS DERIVED, NOT DECLARED. `role` follows the item's menu role
40
+ // (`menuitem`/`menuitemcheckbox`/`menuitemradio`) and `aria-checked` follows `toggled`,
41
+ // so a screen reader hears the same three-way distinction a sighted reader sees. A row
42
+ // the action group disables is `aria-disabled` AND `disabled`, so it is announced and
43
+ // unclickable rather than merely dim.
44
+ //
45
+ // Reference: refs/gtk/gtk/gtkpopovermenu.c (the page stack, the attribute list)
46
+ // Reference: refs/libadwaita/src/stylesheet/widgets/_menus.scss:68-90 (separator, accelerator, check/radio)
47
+ // Copyright (c) GNOME contributors (GTK, libadwaita). LGPLv2.1+.
48
+
49
+ import { flattenMenu, menuNodeAt, resolveMenuItemState } from '@gjsify/adwaita-core';
50
+ import type { AdwMenuActions, AdwMenuItem, AdwMenuModel, AdwMenuPath } from '@gjsify/adwaita-core';
51
+
52
+ import { createGtkImage } from './gtk-image.js';
53
+ import type { GtkPopover } from './gtk-popover.js';
54
+
55
+ /**
56
+ * Called when the reader chooses an item, with the path that addresses it in the WHOLE
57
+ * model — never the item itself. The element resolves it through its own state, which is
58
+ * what dismisses the menu, so handing over a second copy of the item would give the
59
+ * activation two sources.
60
+ */
61
+ export type PopoverMenuActivate = (path: AdwMenuPath) => void;
62
+
63
+ /** The label of the row that leaves a submenu. `GtkPopoverMenu` draws the parent's own label. */
64
+ const BACK_LABEL = 'Back';
65
+
66
+ /**
67
+ * The rows of one popover, and which page is showing.
68
+ *
69
+ * A view rather than a function because a submenu is a NAVIGATION: the open page has to
70
+ * survive between renders, and it has to reset when the popover closes — a menu that
71
+ * reopens three levels deep is a menu the reader cannot get out of.
72
+ */
73
+ export class PopoverMenuView {
74
+ private _model: AdwMenuModel = [];
75
+ private _actions: AdwMenuActions | null = null;
76
+ private _title = '';
77
+ /** The submenu whose items are showing; `[]` is the root page. */
78
+ private _page: AdwMenuPath = [];
79
+ /** The rows that OPEN a page — what ArrowRight acts on. Rebuilt by every render. */
80
+ private _openers = new Set<HTMLElement>();
81
+ /** The row that LEAVES a page — what ArrowLeft acts on. */
82
+ private _backRow: HTMLElement | null = null;
83
+ /** The canonical text of the model showing, so an unchanged re-render keeps the page. */
84
+ private _key = '[]';
85
+ private _rows: HTMLButtonElement[] = [];
86
+
87
+ constructor(
88
+ private readonly surface: GtkPopover,
89
+ /** Class prefix for the element's own hooks — `adw-menu-button`, `adw-split-button-menu`. */
90
+ private readonly prefix: string,
91
+ private readonly onActivate: PopoverMenuActivate,
92
+ ) {
93
+ // On the SURFACE, not on each row: rows are replaced by every render and a
94
+ // per-row listener would have to be rebound with them, which is how one gets
95
+ // forgotten. The surface outlives every page.
96
+ this.surface.addEventListener('keydown', (event) => this._onKeyDown(event));
97
+ }
98
+
99
+ /**
100
+ * LEFT and RIGHT, which change the page. Every other key belongs to `<gtk-popover>`.
101
+ *
102
+ * The act is the row's own CLICK, not a second copy of what opening a page means:
103
+ * `switch_menu` is one behaviour in GTK too, reached by a pointer and by a direction
104
+ * alike. `preventDefault` before it, for the reason the popover's own Enter handler
105
+ * gives — a focused `<button>` would otherwise also activate natively.
106
+ */
107
+ private _onKeyDown(event: KeyboardEvent): void {
108
+ const active = document.activeElement as HTMLElement | null;
109
+ if (active === null || !this.surface.contains(active)) return;
110
+ if (event.key === 'ArrowRight' && this._openers.has(active)) {
111
+ event.preventDefault();
112
+ active.click();
113
+ return;
114
+ }
115
+ if (event.key === 'ArrowLeft' && active === this._backRow) {
116
+ event.preventDefault();
117
+ active.click();
118
+ }
119
+ }
120
+
121
+ /** Every row of the open page, in order — including the ones no key can reach. */
122
+ get rows(): readonly HTMLButtonElement[] {
123
+ return this._rows;
124
+ }
125
+
126
+ /**
127
+ * The rows a key can actually REACH, which is the only list worth focusing into.
128
+ *
129
+ * A hidden row is not rendered and a DISABLED one cannot take focus, so
130
+ * `element.focus()` on either is a silent no-op — and a no-op after `render()` has
131
+ * replaced the row that had focus leaves `document.activeElement` on `<body>`,
132
+ * OUTSIDE an open popover. Measured under real keys: Tab then walked to the control
133
+ * BEHIND the popup and every arrow was dead, because both keydown listeners are
134
+ * element-scoped and the event no longer fired inside either. Escape was the only
135
+ * way out.
136
+ *
137
+ * The predicate lived in the two ELEMENTS (their open path) and not in the two page
138
+ * changes here, which is how it came to be applied in two places out of four. One
139
+ * list, so there is no fourth place to forget.
140
+ */
141
+ get focusableRows(): readonly HTMLButtonElement[] {
142
+ return this._rows.filter((row) => !row.hidden && !row.disabled);
143
+ }
144
+
145
+ /**
146
+ * Put focus on a page, counting only rows a key can reach.
147
+ *
148
+ * `skip` is how many reachable rows to pass — 1 when ENTERING a submenu, because the
149
+ * back row is reachable and the reader was going to the first item past it. The
150
+ * fallback to the first reachable row is what covers a submenu whose every item is
151
+ * disabled: focus lands on the back row, which is always reachable, and the reader
152
+ * can still leave with Left, Enter or Escape.
153
+ */
154
+ private focusPage(skip: number): void {
155
+ const rows = this.focusableRows;
156
+ (rows[skip] ?? rows[0])?.focus();
157
+ }
158
+
159
+ /**
160
+ * Named for the value it takes, and NOT `setModel` — the portable LIST model gave
161
+ * `ComboState` a `setModel` (ADR 0046), and two methods called `setModel` over two
162
+ * unrelated values on one surface is the collision the vocabulary work exists to
163
+ * remove. It also un-blinds `check-adwaita-collection-reactivity`, which resolves a
164
+ * collection call by the setter's name.
165
+ */
166
+ setMenuModel(model: AdwMenuModel): void {
167
+ // A CHANGED model invalidates the open page: the submenu it named may not exist
168
+ // any more, and rendering a page that is gone leaves an empty popover with a
169
+ // back row and no way to see what happened. An UNCHANGED one must not — every
170
+ // element re-render calls this, and resetting unconditionally shut the submenu
171
+ // the reader had just opened.
172
+ const key = JSON.stringify(model);
173
+ if (key !== this._key) {
174
+ this._key = key;
175
+ this._page = [];
176
+ }
177
+ this._model = model;
178
+ }
179
+
180
+ setActions(actions: AdwMenuActions | null): void {
181
+ this._actions = actions;
182
+ }
183
+
184
+ setTitle(title: string): void {
185
+ this._title = title;
186
+ }
187
+
188
+ /** Return to the root page — called when the popover closes. */
189
+ reset(): void {
190
+ this._page = [];
191
+ }
192
+
193
+ /** The items of the page currently showing. */
194
+ private currentItems(): AdwMenuModel {
195
+ if (this._page.length === 0) return this._model;
196
+ const node = menuNodeAt(this._model, this._page);
197
+ return node !== null && node.kind !== 'item' ? node.items : this._model;
198
+ }
199
+
200
+ /** Rebuild the surface's children from the model. */
201
+ render(): void {
202
+ this.surface.replaceChildren();
203
+ this._rows = [];
204
+ this._openers = new Set();
205
+ this._backRow = null;
206
+
207
+ const open = menuNodeAt(this._model, this._page);
208
+ if (this._page.length > 0 && open !== null && open.kind === 'submenu') {
209
+ this.surface.appendChild(this.backRow(open.label));
210
+ } else if (this._title.length > 0) {
211
+ this.surface.appendChild(this.heading(this._title, `${this.prefix}-title`));
212
+ }
213
+
214
+ // Keyed on the section's PATH, never on its label: two sections may be headed
215
+ // the same, and comparing labels swallowed the second heading.
216
+ let headed = '';
217
+ for (const row of flattenMenu(this.currentItems(), this._page)) {
218
+ if (row.separated) this.surface.appendChild(this.separator());
219
+ // A section heading belongs to the SECTION, and `flattenMenu` deliberately
220
+ // does not carry it — a heading is a DOM node, not a menu row, so the label
221
+ // is read off the node the row's path leads through.
222
+ const parentPath = row.path.slice(0, -1);
223
+ const key = parentPath.join('.');
224
+ if (key !== headed) {
225
+ headed = key;
226
+ const heading = this.sectionHeading(parentPath);
227
+ if (heading !== null) {
228
+ this.surface.appendChild(this.heading(heading, 'adw-popover-section-title'));
229
+ }
230
+ }
231
+ if (row.node.kind === 'submenu') {
232
+ this.surface.appendChild(this.submenuRow(row.node.label, row.node.icon, row.path));
233
+ } else {
234
+ this.surface.appendChild(this.itemRow(row.node, row.path));
235
+ }
236
+ }
237
+ }
238
+
239
+ /** The label of the section at `path`, or `null` when it is not a labelled section. */
240
+ private sectionHeading(path: AdwMenuPath): string | null {
241
+ if (path.length <= this._page.length) return null;
242
+ const parent = menuNodeAt(this._model, path);
243
+ return parent !== null && parent.kind === 'section' && parent.label !== undefined ? parent.label : null;
244
+ }
245
+
246
+ private heading(text: string, extraClass: string): HTMLElement {
247
+ const el = document.createElement('div');
248
+ el.className = `adw-popover-title ${extraClass}`;
249
+ el.textContent = text;
250
+ return el;
251
+ }
252
+
253
+ private separator(): HTMLElement {
254
+ const el = document.createElement('div');
255
+ el.className = 'adw-popover-separator';
256
+ // A separator is decoration: it must not be walked as a row by a screen reader
257
+ // any more than it is by `<gtk-popover>`'s `.adw-popover-item` selector.
258
+ el.setAttribute('role', 'separator');
259
+ return el;
260
+ }
261
+
262
+ /** A row, with the class that makes `<gtk-popover>` treat it as navigable. */
263
+ private baseRow(extra: string): HTMLButtonElement {
264
+ const button = document.createElement('button');
265
+ button.type = 'button';
266
+ button.className = `adw-popover-item ${extra}`;
267
+ button.tabIndex = -1;
268
+ this._rows.push(button);
269
+ return button;
270
+ }
271
+
272
+ private backRow(label: string): HTMLButtonElement {
273
+ const button = this.baseRow(`${this.prefix}-item adw-popover-back`);
274
+ button.setAttribute('role', 'menuitem');
275
+ button.appendChild(createGtkImage('go-previous', `${this.prefix}-item-icon`));
276
+ const text = document.createElement('span');
277
+ text.className = `${this.prefix}-item-label`;
278
+ // The parent's own label names where Back goes; `Back` alone is the fallback for
279
+ // a submenu that has none.
280
+ text.textContent = label.length > 0 ? label : BACK_LABEL;
281
+ button.appendChild(text);
282
+ button.addEventListener('click', () => {
283
+ this._page = this._page.slice(0, -1);
284
+ this.render();
285
+ this.focusPage(0);
286
+ });
287
+ this._backRow = button;
288
+ return button;
289
+ }
290
+
291
+ private submenuRow(label: string, icon: string | undefined, path: AdwMenuPath): HTMLButtonElement {
292
+ const button = this.baseRow(`${this.prefix}-item`);
293
+ button.setAttribute('role', 'menuitem');
294
+ // A submenu OPENS something, so it is a menu with a popup, not a command.
295
+ button.setAttribute('aria-haspopup', 'menu');
296
+ if (icon !== undefined && icon.length > 0) {
297
+ button.appendChild(createGtkImage(icon, `${this.prefix}-item-icon`));
298
+ }
299
+ const text = document.createElement('span');
300
+ text.className = `${this.prefix}-item-label`;
301
+ text.textContent = label;
302
+ button.appendChild(text);
303
+ button.appendChild(createGtkImage('go-next', 'adw-popover-item-arrow'));
304
+ button.addEventListener('click', () => {
305
+ this._page = [...path];
306
+ this.render();
307
+ // Past the back row, which is where the reader was going — counted in
308
+ // REACHABLE rows, so a disabled first item does not strand focus on <body>.
309
+ this.focusPage(1);
310
+ });
311
+ this._openers.add(button);
312
+ return button;
313
+ }
314
+
315
+ private itemRow(item: AdwMenuItem, path: AdwMenuPath): HTMLButtonElement {
316
+ const state = resolveMenuItemState(item, this._actions ?? undefined);
317
+ const button = this.baseRow(`${this.prefix}-item`);
318
+ // `hidden-when` removes the row from the reader's world, not just from view:
319
+ // `hidden` on a button also takes it out of the accessibility tree.
320
+ button.hidden = !state.visible;
321
+ button.disabled = !state.sensitive;
322
+ if (!state.sensitive) button.setAttribute('aria-disabled', 'true');
323
+
324
+ if (state.role === 'normal') {
325
+ button.setAttribute('role', 'menuitem');
326
+ } else {
327
+ button.setAttribute('role', state.role === 'check' ? 'menuitemcheckbox' : 'menuitemradio');
328
+ button.setAttribute('aria-checked', String(state.toggled));
329
+ const mark = document.createElement('span');
330
+ mark.className = `adw-popover-item-check ${state.role === 'radio' ? 'radio' : ''}`.trim();
331
+ // The tick is drawn by CSS off `aria-checked`, so an unchecked row still
332
+ // reserves the space and the labels of a group line up.
333
+ button.appendChild(mark);
334
+ }
335
+
336
+ // An item that asked for NO icon gets no icon node — the emptiness of the
337
+ // DECLARED name, not whether it resolved: `<gtk-image>` draws `image-missing`
338
+ // for a name that was given and is merely undrawable, which is the one case a
339
+ // reader needs to see.
340
+ if (item.icon !== undefined && item.icon.length > 0) {
341
+ button.appendChild(createGtkImage(item.icon, `${this.prefix}-item-icon`));
342
+ }
343
+
344
+ const label = document.createElement('span');
345
+ label.className = `${this.prefix}-item-label`;
346
+ // `use-markup` says the label IS Pango markup. The browser has no Pango, and
347
+ // handing author markup to `innerHTML` would be an injection door in a renderer
348
+ // that has no other one, so the text is shown verbatim and the flag is carried
349
+ // for the GTK path. Stated rather than silently honoured.
350
+ label.textContent = item.label;
351
+ button.appendChild(label);
352
+
353
+ if (item.accel !== undefined && item.accel.length > 0) {
354
+ const accel = document.createElement('span');
355
+ accel.className = 'adw-popover-item-accel';
356
+ accel.textContent = item.accel;
357
+ button.appendChild(accel);
358
+ }
359
+
360
+ button.addEventListener('click', () => {
361
+ if (!state.sensitive) return;
362
+ this.onActivate(path);
363
+ });
364
+ return button;
365
+ }
366
+ }
@@ -5,13 +5,20 @@
5
5
  // against (`COMBO_SELECTION_VECTORS`, `@gjsify/adwaita-core/conformance`), replayed
6
6
  // through the API a consumer would use and read back as what a user would see: the
7
7
  // button label, `selected`/`selectedValue` and the `change` stream. `<adw-combo-row>`
8
- // composes the same `ComboState` but cannot drive the table — two of its four step ops
9
- // have no DOM spelling, since its options come from the `items` attribute at connect
10
- // time only (`items` is not observed) and it publishes no select-by-value setter. So
11
- // this element is the browser's driver for both.
8
+ // composes the same `ComboState` and drives the same table from its own DOM
9
+ // (`adw-row-state.spec.ts`) — it could not while two of its four step ops had no DOM
10
+ // spelling there, its model arriving through an attribute at connect time only (#1525).
11
+ // The two are worth driving separately because they part on ONE row, below.
12
12
  import { describe, it, expect } from '@gjsify/unit';
13
13
 
14
- import { COMBO_SELECTION_VECTORS } from '@gjsify/adwaita-core/conformance';
14
+ import {
15
+ COMBO_SELECTION_VECTORS,
16
+ LIST_ITEMS_CHANGED_VECTORS,
17
+ LIST_MODEL_OWNERSHIP_VECTORS,
18
+ LIST_NORMALIZE_VECTORS,
19
+ LIST_PARSE_VECTORS,
20
+ applyListReadback,
21
+ } from '@gjsify/adwaita-core/conformance';
15
22
  import type { ComboSelectionStep, ComboSelectionVector } from '@gjsify/adwaita-core/conformance';
16
23
 
17
24
  import type { GtkDropDown } from './elements/gtk-drop-down.js';
@@ -38,8 +45,8 @@ function openChooser(dd: GtkDropDown): void {
38
45
  */
39
46
  function applyStep(dd: GtkDropDown, step: ComboSelectionStep): void {
40
47
  switch (step.op) {
41
- case 'setOptions':
42
- dd.options = step.options;
48
+ case 'setModel':
49
+ dd.model = step.model;
43
50
  return;
44
51
  case 'setSelectedIndex':
45
52
  dd.selected = step.index;
@@ -129,19 +136,19 @@ export const GtkDropDownTest = async () => {
129
136
  });
130
137
  });
131
138
 
132
- await describe('gtk-drop-down options', async () => {
139
+ await describe('gtk-drop-down model', async () => {
133
140
  await it('accepts a string[] (value === label)', async () => {
134
141
  const dd = makeDropDown();
135
- dd.options = ['One', 'Two', 'Three'];
136
- expect(dd.options.length).toBe(3);
137
- expect(dd.options[0]).toStrictEqual({ value: 'One', label: 'One' });
142
+ dd.model = ['One', 'Two', 'Three'];
143
+ expect(dd.model.length).toBe(3);
144
+ expect(dd.model[0]).toStrictEqual({ value: 'One', label: 'One' });
138
145
  expect(dd.querySelector('.adw-drop-down-label')?.textContent).toBe('One');
139
146
  dd.remove();
140
147
  });
141
148
 
142
149
  await it('accepts {value,label}[] and shows the selected label', async () => {
143
150
  const dd = makeDropDown();
144
- dd.options = [
151
+ dd.model = [
145
152
  { value: 'a', label: 'Apple' },
146
153
  { value: 'b', label: 'Banana' },
147
154
  ];
@@ -151,12 +158,12 @@ export const GtkDropDownTest = async () => {
151
158
  dd.remove();
152
159
  });
153
160
 
154
- await it('parses options from the JSON attribute', async () => {
161
+ await it('parses the model from the JSON attribute', async () => {
155
162
  const dd = document.createElement('gtk-drop-down') as GtkDropDown;
156
- dd.setAttribute('options', '[{"value":"x","label":"Ex"},{"value":"y","label":"Why"}]');
163
+ dd.setAttribute('model', '[{"value":"x","label":"Ex"},{"value":"y","label":"Why"}]');
157
164
  dd.setAttribute('selected', '1');
158
165
  document.body.appendChild(dd);
159
- expect(dd.options.length).toBe(2);
166
+ expect(dd.model.length).toBe(2);
160
167
  expect(dd.selected).toBe(1);
161
168
  expect(dd.selectedValue).toBe('y');
162
169
  dd.remove();
@@ -164,16 +171,101 @@ export const GtkDropDownTest = async () => {
164
171
 
165
172
  await it('renders one popover item per option', async () => {
166
173
  const dd = makeDropDown();
167
- dd.options = ['a', 'b', 'c'];
174
+ dd.model = ['a', 'b', 'c'];
168
175
  expect(dd.querySelectorAll('.adw-drop-down-item').length).toBe(3);
169
176
  dd.remove();
170
177
  });
178
+
179
+ for (const vector of LIST_NORMALIZE_VECTORS) {
180
+ await it(`normalize — ${vector.rule}`, async () => {
181
+ const dd = makeDropDown();
182
+ dd.model = vector.input;
183
+ expect(dd.model).toStrictEqual([...vector.model]);
184
+ expect(
185
+ [...dd.querySelectorAll('.adw-drop-down-item-label')].map((label) => label.textContent),
186
+ ).toStrictEqual(vector.model.map((item) => item.label));
187
+ dd.remove();
188
+ });
189
+ }
190
+
191
+ // The `model` ATTRIBUTE door, driven here as well as on `<adw-combo-row>`: both
192
+ // selectors read it through the SAME `parseListModel`, so a table asserted on one
193
+ // of them says nothing about the other's `attributeChangedCallback` branch — which
194
+ // is the half that was write-once on the row for its whole life (#1525).
195
+ for (const vector of LIST_PARSE_VECTORS) {
196
+ await it(`attribute — ${vector.rule}`, async () => {
197
+ const dd = makeDropDown();
198
+ // Set AFTER connect, so the attribute reaches the callback rather than the
199
+ // seeding path: a total parser has to be total on the live door too.
200
+ dd.setAttribute('model', vector.attribute);
201
+ expect(dd.model).toStrictEqual([...vector.model]);
202
+ dd.remove();
203
+ });
204
+ }
205
+
206
+ // The half no pure test reaches: a replaced model must SPLICE the row buttons, not
207
+ // rebuild them. Identity is what says which happened — and it is what a user feels,
208
+ // because a rebuilt row loses its focus and the popover its scroll position.
209
+ for (const vector of LIST_ITEMS_CHANGED_VECTORS) {
210
+ await it(`items-changed — ${vector.rule}`, async () => {
211
+ const dd = makeDropDown();
212
+ dd.model = vector.previous;
213
+ const before = [...dd.querySelectorAll('.adw-drop-down-item')];
214
+
215
+ dd.model = vector.next;
216
+
217
+ const after = [...dd.querySelectorAll('.adw-drop-down-item')];
218
+ expect(after.map((item) => item.querySelector('.adw-drop-down-item-label')?.textContent)).toStrictEqual(
219
+ vector.next.map((item) => item.label),
220
+ );
221
+ expect(before.filter((item) => after.includes(item)).length).toBe(vector.survivors);
222
+ dd.remove();
223
+ });
224
+ }
225
+
226
+ // WHO OWNS the array `model` hands back — the row buttons are what shows it, for
227
+ // the reason `adw-row-state.spec.ts` gives on the same table.
228
+ for (const vector of LIST_MODEL_OWNERSHIP_VECTORS) {
229
+ await it(`ownership — ${vector.rule}`, async () => {
230
+ const dd = makeDropDown();
231
+ const input = vector.initial.map((item) => ({ ...item }));
232
+ dd.model = input;
233
+ const before = [...dd.querySelectorAll('.adw-drop-down-item')];
234
+
235
+ const mutated = applyListReadback(vector.source === 'input' ? input : dd.model, vector.op);
236
+ if (vector.assign) dd.model = mutated;
237
+
238
+ expect(dd.model).toStrictEqual([...vector.model]);
239
+ const after = [...dd.querySelectorAll('.adw-drop-down-item')];
240
+ expect(after.map((item) => item.querySelector('.adw-drop-down-item-label')?.textContent)).toStrictEqual(
241
+ vector.model.map((item) => item.label),
242
+ );
243
+ expect(before.filter((item) => after.includes(item)).length).toBe(vector.survivors);
244
+ dd.remove();
245
+ });
246
+ }
247
+
248
+ // The defect the splice exposed: each row's click handler used to close over the
249
+ // index it was BUILT at, which a rebuild always refreshed. Under a splice it does
250
+ // not, so the handler asks the list where the row is now.
251
+ await it('a row that a splice shifted still selects itself', async () => {
252
+ const dd = makeDropDown();
253
+ dd.model = ['b', 'c'];
254
+ const rowForB = dd.querySelectorAll('.adw-drop-down-item')[0] as HTMLButtonElement;
255
+ dd.model = ['a', 'b', 'c'];
256
+ expect(dd.querySelectorAll('.adw-drop-down-item')[1]).toBe(rowForB);
257
+
258
+ (dd.querySelector('.adw-drop-down-button') as HTMLButtonElement).click();
259
+ rowForB.click();
260
+ expect(dd.selectedValue).toBe('b');
261
+ dd.remove();
262
+ });
171
263
  });
172
264
 
173
265
  await describe('gtk-drop-down selected ↔ value sync', async () => {
174
266
  await it('selectedValue setter finds the index by value', async () => {
175
267
  const dd = makeDropDown();
176
- dd.options = [
268
+ dd.model = [
177
269
  { value: 'r', label: 'Red' },
178
270
  { value: 'g', label: 'Green' },
179
271
  { value: 'b', label: 'Blue' },
@@ -186,7 +278,7 @@ export const GtkDropDownTest = async () => {
186
278
 
187
279
  await it('selected reflects to the attribute + marks the row', async () => {
188
280
  const dd = makeDropDown();
189
- dd.options = ['a', 'b'];
281
+ dd.model = ['a', 'b'];
190
282
  dd.selected = 1;
191
283
  expect(dd.getAttribute('selected')).toBe('1');
192
284
  const items = dd.querySelectorAll('.adw-drop-down-item');
@@ -197,7 +289,7 @@ export const GtkDropDownTest = async () => {
197
289
 
198
290
  await it('selectedItem returns the descriptor', async () => {
199
291
  const dd = makeDropDown();
200
- dd.options = [{ value: 'k', label: 'Key' }];
292
+ dd.model = [{ value: 'k', label: 'Key' }];
201
293
  expect(dd.selectedItem).toStrictEqual({ value: 'k', label: 'Key' });
202
294
  dd.remove();
203
295
  });
@@ -206,7 +298,7 @@ export const GtkDropDownTest = async () => {
206
298
  // The property setters can run BEFORE connectedCallback builds the DOM, where
207
299
  // _updateLabel would touch an undefined label element.
208
300
  const dd = document.createElement('gtk-drop-down') as GtkDropDown;
209
- dd.options = ['a', 'b', 'c'];
301
+ dd.model = ['a', 'b', 'c'];
210
302
  dd.selected = 2;
211
303
  expect(dd.isConnected).toBe(false);
212
304
  document.body.appendChild(dd);
@@ -220,7 +312,7 @@ export const GtkDropDownTest = async () => {
220
312
  await describe('gtk-drop-down notify-on-change', async () => {
221
313
  await it('a programmatic set fires notify::selected but NOT change (DOM <select> semantics)', async () => {
222
314
  const dd = makeDropDown();
223
- dd.options = ['a', 'b', 'c'];
315
+ dd.model = ['a', 'b', 'c'];
224
316
  let notifyCount = 0;
225
317
  let changeCount = 0;
226
318
  dd.addEventListener('notify::selected', () => notifyCount++);
@@ -243,7 +335,7 @@ export const GtkDropDownTest = async () => {
243
335
 
244
336
  await it('a popover item click selects, fires change + closes', async () => {
245
337
  const dd = makeDropDown();
246
- dd.options = ['a', 'b', 'c'];
338
+ dd.model = ['a', 'b', 'c'];
247
339
  let change: unknown = null;
248
340
  dd.addEventListener('change', (e) => {
249
341
  change = (e as CustomEvent).detail;
@@ -262,7 +354,7 @@ export const GtkDropDownTest = async () => {
262
354
  await describe('gtk-drop-down popover behaviour', async () => {
263
355
  await it('opens on button click and closes on outside pointerdown', async () => {
264
356
  const dd = makeDropDown();
265
- dd.options = ['a', 'b'];
357
+ dd.model = ['a', 'b'];
266
358
  const button = dd.querySelector('.adw-drop-down-button') as HTMLButtonElement;
267
359
  const popover = dd.querySelector('.adw-drop-down-popover') as HTMLElement;
268
360
  expect(popover.hidden).toBe(true);
@@ -278,7 +370,7 @@ export const GtkDropDownTest = async () => {
278
370
 
279
371
  await it('a disabled dropdown does not open', async () => {
280
372
  const dd = makeDropDown();
281
- dd.options = ['a', 'b'];
373
+ dd.model = ['a', 'b'];
282
374
  dd.setAttribute('disabled', '');
283
375
  (dd.querySelector('.adw-drop-down-button') as HTMLButtonElement).click();
284
376
  expect(dd.active).toBe(false);
@@ -289,7 +381,7 @@ export const GtkDropDownTest = async () => {
289
381
  await describe('gtk-drop-down search', async () => {
290
382
  await it('renders a search entry and filters the list', async () => {
291
383
  const dd = makeDropDown();
292
- dd.options = ['Apple', 'Banana', 'Cherry'];
384
+ dd.model = ['Apple', 'Banana', 'Cherry'];
293
385
  dd.enableSearch = true;
294
386
  const search = dd.querySelector('.adw-drop-down-search') as HTMLInputElement;
295
387
  expect(search).toBeTruthy();
@@ -124,7 +124,7 @@ export const GtkImageTest = async () => {
124
124
 
125
125
  await it('<gtk-menu-button> menu entries guard their icons too', () => {
126
126
  const { el, host } = mount<GtkMenuButton>('gtk-menu-button');
127
- el.menuItems = [
127
+ el.menuModel = [
128
128
  { label: 'Bad', icon: 'a b' },
129
129
  { label: 'Good', icon: 'view-refresh-symbolic' },
130
130
  { label: 'None' },
@@ -151,8 +151,8 @@ export const GtkImageTest = async () => {
151
151
  <gtk-button icon="go-next" label="Next"></gtk-button>
152
152
  <adw-button-row title="Row" start-icon-name="list-add" end-icon-name="go-next"></adw-button-row>
153
153
  <adw-status-page icon="folder" title="Empty"></adw-status-page>
154
- <adw-split-button label="Save" menu='[{"label":"Save As"}]'></adw-split-button>
155
- <gtk-drop-down options='["a","b"]'></gtk-drop-down>
154
+ <adw-split-button label="Save" menu-model='[{"label":"Save As"}]'></adw-split-button>
155
+ <gtk-drop-down model='["a","b"]'></gtk-drop-down>
156
156
  <adw-expander-row title="More" show-enable-switch></adw-expander-row>
157
157
  <adw-avatar text="Ada" icon="avatar-default"></adw-avatar>
158
158
  <adw-toggle-group><adw-toggle icon-name="view-list"></adw-toggle></adw-toggle-group>