@gjsify/adwaita-web 0.64.12-next.281 → 0.64.12

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,4 +1,4 @@
1
- import type { AdwComboOption, AdwListModelInput } from '@gjsify/adwaita-core';
1
+ import type { AdwComboOption, ComboWatchableModel } from '@gjsify/adwaita-core';
2
2
  import { TooltipElement } from '../widget-element.js';
3
3
  export declare class AdwComboRow extends TooltipElement {
4
4
  private _select;
@@ -9,19 +9,36 @@ export declare class AdwComboRow extends TooltipElement {
9
9
  private _subtitleEl;
10
10
  /** The headless options list + selectedIndex↔selectedValue state machine (ADR 0004). */
11
11
  private readonly _state;
12
+ /**
13
+ * The list the MARKUP doors fill — the `model` ATTRIBUTE is JSON text and a
14
+ * `<gtk-string-list slot="model">` child is strings, and a real `Gio.ListModel` has no
15
+ * spelling in either (ADR 0122 § 5). Held for the element's whole life so re-parsing the
16
+ * attribute splices the list the element already points at rather than replacing it, which
17
+ * would reset the selection.
18
+ */
19
+ private readonly _authored;
12
20
  private _initialized;
13
21
  static get observedAttributes(): string[];
14
- /** The list model (`Adw.ComboRow:model`). Setting it splices the <select> and clamps the selection. */
15
- get model(): AdwComboOption[];
16
- set model(value: AdwListModelInput);
22
+ /**
23
+ * The list model (`Adw.ComboRow:model`) — the OBJECT that was assigned, by identity, as
24
+ * `row.model === model` is measured to hold in libadwaita (ADR 0122 § 4). The normalised
25
+ * descriptors are {@link options}. Assigning splices the `<select>` and re-runs autoselect.
26
+ */
27
+ get model(): ComboWatchableModel | null;
28
+ set model(value: ComboWatchableModel | string | null);
29
+ /** The held model's items as `{ value, label }` descriptors. */
30
+ get options(): AdwComboOption[];
17
31
  /** `adw_combo_row_set_model`. */
18
- set_model(model: AdwListModelInput | null): void;
32
+ set_model(model: ComboWatchableModel | null): void;
33
+ /** `adw_combo_row_get_model`. */
34
+ get_model(): ComboWatchableModel | null;
19
35
  /**
20
- * The selected index (Adw.ComboRow:selected). PERMISSIVE: an index past the end is
21
- * accepted and reads back with an empty {@link selectedValue}, mirroring the `guint`
22
- * property that takes any position — where `<gtk-drop-down>` rejects the same set, as
23
- * its own docs promise. `ComboState.hasIndex` owns the bounds and records why the
24
- * POLICY is each renderer's; `COMBO_SELECTION_VECTORS` drives both answers.
36
+ * The selected index (Adw.ComboRow:selected). An index past the end is IGNORED — the
37
+ * selection stays where it was, which is what libadwaita is measured to do (ADR 0122 § 4,
38
+ * `combo-probe2.js`) and what `<gtk-drop-down>` already did. This element used to ACCEPT
39
+ * such a set and read back an empty {@link selectedValue}, the one row of
40
+ * `COMBO_SELECTION_VECTORS` the two selectors answered differently; the permissive half
41
+ * was ours, not GTK's, and it is gone. `ComboState.hasIndex` owns the bounds.
25
42
  */
26
43
  get selected(): number;
27
44
  set selected(value: number);
@@ -38,13 +55,15 @@ export declare class AdwComboRow extends TooltipElement {
38
55
  get selectedValue(): string;
39
56
  set selectedValue(value: string);
40
57
  /**
41
- * The selected option descriptor, or `null` (Adw.ComboRow:selected-item).
58
+ * The selected ITEM, or `null` (Adw.ComboRow:selected-item).
42
59
  *
43
- * Read-only, as the GObject property is. `null` and not a blank descriptor: the
44
- * sentinel and an index past the end both address no option, and inventing an empty
45
- * one would make "nothing is selected" indistinguishable from an option with no text.
60
+ * Read-only, as the GObject property is, and the MODEL'S OWN object rather than a
61
+ * descriptor, so the identity comparison a `selected-item` consumer writes holds (ADR
62
+ * 0122 § 4). `null` and not a blank item: the sentinel and an index past the end both
63
+ * address no option, and inventing an empty one would make "nothing is selected"
64
+ * indistinguishable from an option with no text.
46
65
  */
47
- get selectedItem(): AdwComboOption | null;
66
+ get selectedItem(): unknown;
48
67
  connectedCallback(): void;
49
68
  attributeChangedCallback(name: string, _old: string | null, value: string | null): void;
50
69
  /**
@@ -1,5 +1,5 @@
1
1
  import './gtk-popover.js';
2
- import type { AdwComboOption, AdwListModelInput } from '@gjsify/adwaita-core';
2
+ import type { AdwComboOption, ComboWatchableModel } from '@gjsify/adwaita-core';
3
3
  import type { DispatchedSignals } from '../signals.js';
4
4
  import { WidgetElement } from '../widget-element.js';
5
5
  /**
@@ -19,13 +19,31 @@ export declare class GtkDropDown extends WidgetElement {
19
19
  private _itemButtons;
20
20
  /** The headless options list + selectedIndex↔selectedValue machine (ADR 0004). */
21
21
  private readonly _state;
22
+ /**
23
+ * The list the MARKUP doors fill — the `model` ATTRIBUTE is JSON text and a
24
+ * `<gtk-string-list slot="model">` child is strings, and a real `Gio.ListModel` has no
25
+ * spelling in either (ADR 0122 § 5). Held for the element's whole life so re-parsing the
26
+ * attribute splices the list the element already points at rather than replacing it, which
27
+ * would reset the selection.
28
+ */
29
+ private readonly _authored;
22
30
  private _initialized;
23
31
  private _typeAheadBuffer;
24
32
  private _typeAheadTimer;
25
33
  static get observedAttributes(): string[];
26
- /** The list model (`Gtk.DropDown:model`). Setting it splices the popover and clamps the selection. */
27
- get model(): GtkDropDownOption[];
28
- set model(value: AdwListModelInput);
34
+ /**
35
+ * The list model (`Gtk.DropDown:model`) — the OBJECT that was assigned, by identity, as
36
+ * `dropdown.model === model` is measured to hold in GTK (ADR 0122 § 4). The normalised
37
+ * descriptors are {@link options}. Assigning splices the popover and re-runs autoselect.
38
+ */
39
+ get model(): ComboWatchableModel | null;
40
+ set model(value: ComboWatchableModel | string | null);
41
+ /** The held model's items as `{ value, label }` descriptors. */
42
+ get options(): AdwComboOption[];
43
+ /** `gtk_drop_down_set_model`. */
44
+ set_model(model: ComboWatchableModel | null): void;
45
+ /** `gtk_drop_down_get_model`. */
46
+ get_model(): ComboWatchableModel | null;
29
47
  /** The selected index. Out-of-range values are ignored (Gtk.DropDown:selected). */
30
48
  get selected(): number;
31
49
  set selected(value: number);
@@ -36,7 +54,15 @@ export declare class GtkDropDown extends WidgetElement {
36
54
  /** The selected option's value, or '' when empty (Gtk.DropDown:selected-item). */
37
55
  get selectedValue(): string;
38
56
  set selectedValue(value: string);
39
- get selectedItem(): GtkDropDownOption | null;
57
+ /**
58
+ * The selected ITEM, or `null` (Gtk.DropDown:selected-item).
59
+ *
60
+ * The MODEL'S OWN object rather than a descriptor, so the identity comparison a
61
+ * `selected-item` consumer writes holds (ADR 0122 § 4).
62
+ */
63
+ get selectedItem(): unknown;
64
+ /** `gtk_drop_down_get_selected_item`. */
65
+ get_selected_item(): unknown;
40
66
  get enableSearch(): boolean;
41
67
  set enableSearch(value: boolean);
42
68
  get active(): boolean;
@@ -8,4 +8,4 @@ export declare const ContentProvider: Pick<typeof import("@gjsify/adwaita-core")
8
8
  export declare const ContentFormats: typeof import("@gjsify/adwaita-core").ContentFormats;
9
9
  export declare const AppLaunchContext: typeof import("@gjsify/adwaita-core").GdkAppLaunchContext;
10
10
  export declare const CURRENT_TIME = 0;
11
- export declare const KEY_space: 32, KEY_a: 97, KEY_d: 100, KEY_e: 101, KEY_q: 113, KEY_s: 115, KEY_w: 119, KEY_Return: 65293, KEY_Escape: 65307, KEY_Left: 65361, KEY_Up: 65362, KEY_Right: 65363, KEY_Down: 65364;
11
+ export declare const KEY_space: 32, KEY_a: 97, KEY_d: 100, KEY_e: 101, KEY_q: 113, KEY_s: 115, KEY_w: 119, KEY_Return: 65293, KEY_Escape: 65307, KEY_Left: 65361, KEY_Up: 65362, KEY_Right: 65363, KEY_Down: 65364, KEY_Back: 269025062;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/adwaita-web",
3
- "version": "0.64.12-next.281",
3
+ "version": "0.64.12",
4
4
  "description": "Adwaita/Libadwaita web components for browser targets",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -58,14 +58,14 @@
58
58
  "@codemirror/language": "^6.12.4",
59
59
  "@codemirror/state": "^6.7.0",
60
60
  "@codemirror/view": "^6.43.4",
61
- "@gjsify/adwaita-core": "0.64.12-next.281",
62
- "@gjsify/adwaita-fonts": "0.64.12-next.281",
63
- "@gjsify/adwaita-icons": "0.64.12-next.281",
64
- "@gjsify/gtksource-core": "0.64.12-next.281",
61
+ "@gjsify/adwaita-core": "^0.64.12",
62
+ "@gjsify/adwaita-fonts": "^0.64.12",
63
+ "@gjsify/adwaita-icons": "^0.64.12",
64
+ "@gjsify/gtksource-core": "^0.64.12",
65
65
  "@lezer/highlight": "^1.2.3"
66
66
  },
67
67
  "peerDependencies": {
68
- "@gjsify/app-settings": "0.64.12-next.281"
68
+ "@gjsify/app-settings": "^0.64.12"
69
69
  },
70
70
  "peerDependenciesMeta": {
71
71
  "@gjsify/app-settings": {
@@ -73,11 +73,11 @@
73
73
  }
74
74
  },
75
75
  "devDependencies": {
76
- "@gjsify/app-settings": "0.64.12-next.281",
77
- "@gjsify/blueprint": "0.64.12-next.281",
78
- "@gjsify/cli": "0.64.12-next.281",
79
- "@gjsify/unit": "0.64.12-next.281",
80
- "@gjsify/vite-plugin-blueprint": "0.64.12-next.281",
76
+ "@gjsify/app-settings": "^0.64.12",
77
+ "@gjsify/blueprint": "^0.64.12",
78
+ "@gjsify/cli": "^0.64.12",
79
+ "@gjsify/unit": "^0.64.12",
80
+ "@gjsify/vite-plugin-blueprint": "^0.64.12",
81
81
  "sass": "^1.101.0",
82
82
  "typescript": "^6.0.3"
83
83
  },
@@ -114,6 +114,5 @@
114
114
  "bugs": {
115
115
  "url": "https://github.com/gjsify/gjsify/issues"
116
116
  },
117
- "homepage": "https://github.com/gjsify/gjsify/tree/main/packages/web/adwaita-web#readme",
118
- "gitHead": "c4d2e266c0d9849860b9828c14ebfe37b6d493b8"
117
+ "homepage": "https://github.com/gjsify/gjsify/tree/main/packages/web/adwaita-web#readme"
119
118
  }
@@ -13,15 +13,18 @@ import {
13
13
  ADJUSTMENT_AUTHORED_VECTORS,
14
14
  ADJUSTMENT_PARSE_VECTORS,
15
15
  COMBO_CHOOSER_VECTORS,
16
+ COMBO_ITEMS_CHANGED_VECTORS,
16
17
  COMBO_SELECTION_VECTORS,
17
18
  LIST_ITEMS_CHANGED_VECTORS,
18
19
  LIST_MODEL_OWNERSHIP_VECTORS,
19
20
  LIST_NORMALIZE_VECTORS,
20
21
  LIST_PARSE_VECTORS,
21
- LIST_SELECTION_CLAMP_VECTORS,
22
+ applyListMutation,
22
23
  applyListReadback,
24
+ listModelForInput,
23
25
  } from '@gjsify/adwaita-core/conformance';
24
26
  import type { ComboSelectionStep } from '@gjsify/adwaita-core/conformance';
27
+ import { GjsifyOptionList } from '@gjsify/adwaita-core';
25
28
 
26
29
  import type { AdwComboRow } from './elements/adw-combo-row.js';
27
30
  import type { AdwExpanderRow } from './elements/adw-expander-row.js';
@@ -51,17 +54,24 @@ function record(el: HTMLElement, event: string): { details: unknown[]; stop: ()
51
54
  *
52
55
  * The row can drive the table at all only because the model and the select-by-value are
53
56
  * now published; the conformance header names that as the condition for it inheriting
54
- * these rows. It drives EVERY row, including `an index past the end`, which
55
- * `<gtk-drop-down>` carves out: the row keeps `ComboState`'s permissive answer where the
56
- * drop-down's published `selected` contract rejects the set, and `ComboState.hasIndex`'s
57
- * doc is where that split is stated. So the carve-out is now visible from both sides.
57
+ * these rows. It drives EVERY row with no carve-out — `an index past the end` used to be
58
+ * the one the two browser selectors answered differently and ADR 0122 measured it instead,
59
+ * so the permissive half this element carried is gone.
60
+ *
61
+ * Every `setModel` builds a FRESH list, which is what makes `setModelAgain` — assigning the
62
+ * identity the row already holds — a distinct step rather than a repeat of the one above it.
58
63
  */
59
64
  function applyComboStep(row: AdwComboRow, step: ComboSelectionStep): void {
60
65
  const select = row.querySelector('select') as HTMLSelectElement;
61
66
  switch (step.op) {
62
67
  case 'setModel':
63
- row.model = step.model;
68
+ row.model = GjsifyOptionList.new([...step.model]);
69
+ return;
70
+ case 'setModelAgain': {
71
+ const held = row.model;
72
+ row.model = held;
64
73
  return;
74
+ }
65
75
  case 'setSelectedIndex':
66
76
  row.selected = step.index;
67
77
  return;
@@ -155,7 +165,7 @@ export const AdwRowStateTest = async () => {
155
165
 
156
166
  // Through the PUBLISHED path a consumer has — the same `model` property
157
167
  // `<gtk-drop-down>` publishes, not a reach into the composed state.
158
- row.model = ['One', 'Two'];
168
+ row.model = GjsifyOptionList.new(['One', 'Two']);
159
169
  expect(select.disabled).toBe(false);
160
170
  expect(row.dataset.presentsChooser).toBe('true');
161
171
 
@@ -177,10 +187,10 @@ export const AdwRowStateTest = async () => {
177
187
  const value = row.querySelector('.adw-row-value') as HTMLSpanElement;
178
188
  expect(select.options.length).toBe(2);
179
189
 
180
- // The PROPERTY, with the plain-string vocabulary both selectors accept.
181
- row.model = ['Cyan', 'Magenta', 'Yellow'];
190
+ // The PROPERTY, with the plain-string vocabulary `GjsifyOptionList` accepts.
191
+ row.model = GjsifyOptionList.new(['Cyan', 'Magenta', 'Yellow']);
182
192
  expect([...select.options].map((o) => o.textContent).join('|')).toBe('Cyan|Magenta|Yellow');
183
- expect(row.model.map((o) => o.label).join('|')).toBe('Cyan|Magenta|Yellow');
193
+ expect(row.options.map((o) => o.label).join('|')).toBe('Cyan|Magenta|Yellow');
184
194
  // The model was replaced under an index the new one still has, so autoselect
185
195
  // keeps 0 and the inline label has to follow the NEW option at that index.
186
196
  expect(value.textContent).toBe('Cyan');
@@ -205,12 +215,12 @@ export const AdwRowStateTest = async () => {
205
215
  );
206
216
  const select = row.querySelector('select') as HTMLSelectElement;
207
217
 
208
- row.model = [
218
+ row.model = GjsifyOptionList.new([
209
219
  { value: 'r', label: 'Red' },
210
220
  { value: 'g', label: 'Green' },
211
- ];
221
+ ]);
212
222
  expect([...select.options].map((o) => o.textContent).join('|')).toBe('Red|Green');
213
- expect(row.model.map((o) => o.value).join('|')).toBe('r|g');
223
+ expect(row.options.map((o) => o.value).join('|')).toBe('r|g');
214
224
 
215
225
  // And selection by VALUE, the drop-down's other published setter.
216
226
  row.selectedValue = 'g';
@@ -223,42 +233,50 @@ export const AdwRowStateTest = async () => {
223
233
  // The row publishes the drop-down's property set apart from `enableSearch` and
224
234
  // `active`, which are that element's popover chrome. `selectedItem` is NOT chrome —
225
235
  // it is `Adw.ComboRow:selected-item` — so it is here, read-only as the GObject
226
- // property is, and `null` rather than a blank descriptor when nothing is addressed.
227
- await it('reads the selected descriptor back, and null when nothing is addressed', async () => {
236
+ // property is, and `null` rather than a blank item when nothing is addressed.
237
+ //
238
+ // THE MODEL'S OWN ITEM, by identity (ADR 0122 § 4): `row.selectedItem ===
239
+ // model.get_item(row.selected)`, which is the comparison a `selected-item` consumer
240
+ // writes and the one a normalised descriptor would have failed.
241
+ await it('reads the selected item back by identity, and null when nothing is addressed', async () => {
228
242
  const { el: row, host } = parse<AdwComboRow>(
229
243
  `<adw-combo-row title="Pick"></adw-combo-row>`,
230
244
  'adw-combo-row',
231
245
  );
232
246
 
247
+ // No model at all — `null` and not an item of an empty list.
248
+ expect(row.model).toBeNull();
233
249
  expect(row.selectedItem).toBeNull();
234
250
 
235
- row.model = [
251
+ const model = GjsifyOptionList.new([
236
252
  { value: 'r', label: 'Red' },
237
253
  { value: 'g', label: 'Green' },
238
- ];
239
- expect(row.selectedItem?.value).toBe('r');
254
+ ]);
255
+ row.model = model;
256
+ expect(row.model === model).toBe(true);
257
+ expect(row.selectedItem === model.get_item(0)).toBe(true);
240
258
  row.selected = 1;
241
- expect(row.selectedItem?.label).toBe('Green');
259
+ expect(row.selectedItem === model.get_item(1)).toBe(true);
242
260
 
243
- // The one row of the shared contract the two selectors answer differently: an
244
- // index past the end is ACCEPTED here (`ComboState.setSelectedIndex` mirrors a
245
- // guint property) where `<gtk-drop-down>` rejects the set. So the index moves,
246
- // and everything read out of the model reads empty.
261
+ // An index past the end is IGNORED, as measured in libadwaita — the selection
262
+ // stays where it was, and nothing reads empty. This element used to ACCEPT the
263
+ // set and read back blank, the one row of the shared table the two browser
264
+ // selectors answered differently; the permissive half was ours, not GTK's.
247
265
  row.selected = 9;
248
- expect(row.selected).toBe(9);
249
- expect(row.selectedItem).toBeNull();
250
- expect(row.selectedValue).toBe('');
251
- expect((row.querySelector('.adw-row-value') as HTMLSpanElement).textContent).toBe('');
266
+ expect(row.selected).toBe(1);
267
+ expect(row.selectedValue).toBe('g');
268
+ expect((row.querySelector('.adw-row-value') as HTMLSpanElement).textContent).toBe('Green');
252
269
 
253
270
  host.remove();
254
271
  });
255
272
  });
256
273
 
257
- // The portable list model (ADR 0046), driven through this row's own `model` door.
274
+ // The portable list model (ADR 0046), driven through this row's own `model` doors — the
275
+ // property for a real model, the attribute for markup.
258
276
  //
259
277
  // The ITEMS-CHANGED rows are the ones that could not be asserted anywhere else: they
260
- // compare `<option>` node IDENTITY across a replacement, which is the difference
261
- // between splicing the model and rebuilding it — a difference no assertion about the
278
+ // compare `<option>` node IDENTITY across a markup update, which is the difference
279
+ // between splicing the list and rebuilding it — a difference no assertion about the
262
280
  // model, the labels or the selection can see. Before the splice landed, every row with
263
281
  // a non-zero `survivors` scored 0.
264
282
  await describe('adw-combo-row list model (portable list-model vectors)', async () => {
@@ -268,8 +286,10 @@ export const AdwRowStateTest = async () => {
268
286
  `<adw-combo-row title="Pick"></adw-combo-row>`,
269
287
  'adw-combo-row',
270
288
  );
271
- row.model = vector.input;
272
- expect(row.model).toStrictEqual([...vector.model]);
289
+ // `listModelForInput` is the table's own answer to which door a row reaches
290
+ // through now that `model` takes no array (ADR 0122 § 4).
291
+ row.model = listModelForInput(vector.input);
292
+ expect(row.options).toStrictEqual([...vector.model]);
273
293
  const select = row.querySelector('select') as HTMLSelectElement;
274
294
  expect([...select.options].map((option) => option.textContent)).toStrictEqual(
275
295
  vector.model.map((item) => item.label),
@@ -287,7 +307,7 @@ export const AdwRowStateTest = async () => {
287
307
  // Set AFTER connect, so the attribute reaches the callback rather than the
288
308
  // seeding path: a total parser has to be total on the live door too.
289
309
  row.setAttribute('model', vector.attribute);
290
- expect(row.model).toStrictEqual([...vector.model]);
310
+ expect(row.options).toStrictEqual([...vector.model]);
291
311
  host.remove();
292
312
  });
293
313
  }
@@ -299,10 +319,16 @@ export const AdwRowStateTest = async () => {
299
319
  'adw-combo-row',
300
320
  );
301
321
  const select = row.querySelector('select') as HTMLSelectElement;
302
- row.model = vector.previous;
322
+ // Driven through the MARKUP door, which is the one that still computes a
323
+ // splice: the attribute reconciles the element's own authored list (ADR 0122
324
+ // amendment A4), keeping its identity, so the survivors these rows count are
325
+ // the `<option>` nodes a minimal splice left alone. A `model` PROPERTY write
326
+ // cannot answer them any more — a different list is a replacement, and a
327
+ // replacement carries nothing over by definition.
328
+ row.setAttribute('model', JSON.stringify(vector.previous));
303
329
  const before = [...select.options];
304
330
 
305
- row.model = vector.next;
331
+ row.setAttribute('model', JSON.stringify(vector.next));
306
332
 
307
333
  const after = [...select.options];
308
334
  expect(after.map((option) => option.textContent)).toStrictEqual(vector.next.map((item) => item.label));
@@ -311,10 +337,10 @@ export const AdwRowStateTest = async () => {
311
337
  });
312
338
  }
313
339
 
314
- // WHO OWNS the array `model` hands back. The DOM is what makes these rows worth
315
- // driving at the element: without the copy the MODEL still reads correctly on the
316
- // mutate-then-assign rows — it is `_options` itself that was mutated — and only the
317
- // `<option>` list shows that no splice was ever emitted.
340
+ // WHO OWNS the options on either side of the model, and what the model's own API
341
+ // moves. The DOM is what makes these rows worth driving at the element: on the
342
+ // caller-side rows the descriptors still read correctly from the model itself, and
343
+ // only the `<option>` list shows whether a splice was emitted for them.
318
344
  for (const vector of LIST_MODEL_OWNERSHIP_VECTORS) {
319
345
  await it(`ownership — ${vector.rule}`, async () => {
320
346
  const { el: row, host } = parse<AdwComboRow>(
@@ -323,39 +349,23 @@ export const AdwRowStateTest = async () => {
323
349
  );
324
350
  const select = row.querySelector('select') as HTMLSelectElement;
325
351
  const input = vector.initial.map((item) => ({ ...item }));
326
- row.model = input;
352
+ const list = GjsifyOptionList.new(input);
353
+ row.model = list;
327
354
  const before = [...select.options];
328
355
 
329
- const mutated = applyListReadback(vector.source === 'input' ? input : row.model, vector.op);
330
- if (vector.assign) row.model = mutated;
356
+ if (vector.source === 'model') applyListMutation(list, vector.op);
357
+ else applyListReadback(vector.source === 'input' ? input : row.options, vector.op);
331
358
 
332
- expect(row.model).toStrictEqual([...vector.model]);
359
+ expect(row.options).toStrictEqual([...vector.model]);
360
+ // None of these ops is a replacement, so the element must still be pointing
361
+ // at the list it was handed.
362
+ expect(row.model).toBe(list);
333
363
  const after = [...select.options];
334
364
  expect(after.map((option) => option.textContent)).toStrictEqual(vector.model.map((item) => item.label));
335
365
  expect(before.filter((option) => after.includes(option)).length).toBe(vector.survivors);
336
366
  host.remove();
337
367
  });
338
368
  }
339
-
340
- for (const vector of LIST_SELECTION_CLAMP_VECTORS) {
341
- await it(`autoselect — ${vector.rule}`, async () => {
342
- const { el: row, host } = parse<AdwComboRow>(
343
- `<adw-combo-row title="Pick"></adw-combo-row>`,
344
- 'adw-combo-row',
345
- );
346
- // Seed a model long enough to hold the starting index, then replace it with
347
- // one of the vector's length. NARROWER THAN THE VECTOR ON ONE ROW, and it
348
- // says so: this element cannot HOLD a non-finite index — `setSelectedIndex`
349
- // folds it to 0 before the clamp is reached — so that row asserts the same
350
- // outcome by a different route here, and the non-finite input itself is
351
- // driven in the core suite.
352
- row.model = Array.from({ length: 8 }, (_, i) => `item ${i}`);
353
- row.setAttribute('selected', String(vector.selected));
354
- row.model = Array.from({ length: vector.length }, (_, i) => `next ${i}`);
355
- expect(row.selected).toBe(vector.result);
356
- host.remove();
357
- });
358
- }
359
369
  });
360
370
 
361
371
  // The SAME table `<gtk-drop-down>` and the NativeScript renderer are held to
@@ -394,6 +404,53 @@ export const AdwRowStateTest = async () => {
394
404
  }
395
405
  });
396
406
 
407
+ // The model MOVING under a live row — the axis that only exists since `model` holds a
408
+ // real `Gio.ListModel` (ADR 0122 § 6). Nothing re-assigns `model` here: the list is
409
+ // spliced through its own API and the row has to follow, which is what separates this
410
+ // from every replacement row above it.
411
+ //
412
+ // What a DOM surface can show is the OUTCOME — `selected`, `selectedValue`, the inline
413
+ // label and the rebuilt `<option>` list — plus the silence: a model moving underneath is
414
+ // not a user pick, so `notify::selected` must not fire. The vectors' `notified` column is
415
+ // the GObject half this element has no spelling for and the core suite asserts alone.
416
+ await describe('adw-combo-row items-changed on the held model (GtkSingleSelection)', async () => {
417
+ for (const vector of COMBO_ITEMS_CHANGED_VECTORS) {
418
+ await it(`${vector.name} — ${vector.rule}`, async () => {
419
+ const { el: row, host } = parse<AdwComboRow>(
420
+ `<adw-combo-row title="Pick"></adw-combo-row>`,
421
+ 'adw-combo-row',
422
+ );
423
+ const select = row.querySelector('select') as HTMLSelectElement;
424
+ const value = row.querySelector('.adw-row-value') as HTMLSpanElement;
425
+
426
+ const list = GjsifyOptionList.new([...vector.initial]);
427
+ row.model = list;
428
+ if (vector.selected >= 0) row.selected = vector.selected;
429
+ expect(row.selected, 'the row set up the selection it says it did').toBe(vector.selected);
430
+
431
+ const events = record(row, 'notify::selected');
432
+ list.splice(vector.splice.position, vector.splice.removed, [...vector.splice.added]);
433
+
434
+ const expected = [...vector.initial];
435
+ expected.splice(vector.splice.position, vector.splice.removed, ...vector.splice.added);
436
+
437
+ expect(row.selected).toBe(vector.result);
438
+ expect(row.selectedValue).toBe(vector.value);
439
+ expect(value.textContent).toBe(expected[vector.result]?.label ?? '');
440
+ // The drawn list follows the MODEL: the row was never re-assigned, so a
441
+ // `<select>` still showing the old options is exactly the bug the
442
+ // `items-changed` channel exists to prevent.
443
+ expect([...select.options].map((option) => option.textContent)).toStrictEqual(
444
+ expected.map((option) => option.label),
445
+ );
446
+ expect(events.details).toStrictEqual([]);
447
+
448
+ events.stop();
449
+ host.remove();
450
+ });
451
+ }
452
+ });
453
+
397
454
  await describe('adw-spin-row (SpinState)', async () => {
398
455
  // THE SHARED TABLES, driven through the ELEMENT and its markup door — the same rows
399
456
  // the core suite and the NativeScript suite assert, so a range authored in one
@@ -9,6 +9,7 @@ import {
9
9
  type ConstructVector,
10
10
  type SharedTreeNode,
11
11
  } from '@gjsify/adwaita-core/conformance';
12
+ import { GjsifyOptionList } from '@gjsify/adwaita-core';
12
13
  import { describe, expect, it } from '@gjsify/unit';
13
14
 
14
15
  import type { AdwAlertDialog } from './elements/adw-alert-dialog.js';
@@ -280,7 +281,7 @@ export const AdwConstructVectorsTest = async () => {
280
281
  });
281
282
  await it('dispatches DropDown `notify::selected-item` after `notify::selected`, on a real change only', () => {
282
283
  const dropDown = document.createElement('gtk-drop-down') as HTMLElement & {
283
- model: string[];
284
+ model: unknown;
284
285
  selected: number;
285
286
  };
286
287
  const seen: string[] = [];
@@ -288,7 +289,8 @@ export const AdwConstructVectorsTest = async () => {
288
289
  dropDown.addEventListener(name, () => seen.push(name));
289
290
  document.body.append(dropDown);
290
291
  try {
291
- dropDown.model = ['a', 'b'];
292
+ // `model` takes a real list model, never an array (ADR 0122 § 4).
293
+ dropDown.model = GjsifyOptionList.new(['a', 'b']);
292
294
  seen.length = 0;
293
295
  dropDown.selected = 1;
294
296
  dropDown.selected = 1;