@gjsify/adwaita-web 0.64.12-next.280 → 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,7 +1,7 @@
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.
1
+ // <adw-combo-row> — row with a title/subtitle and a dropdown select. The model is a real
2
+ // `Gio.ListModel` on the PROPERTY and a JSON array in the `model` ATTRIBUTE (`["a","b"]` or
3
+ // `[{"value":"a","label":"A"}]`), `selected` an index, and the native <select> is stretched
4
+ // invisibly over the row so clicking anywhere opens it.
5
5
  //
6
6
  // The SELECTION state machine (the model, the two-way index↔value mapping, the
7
7
  // empty/out-of-range guards and the programmatic-vs-interactive notify split) is HEADLESS
@@ -16,6 +16,13 @@
16
16
  // `scripts/check-adwaita-collection-reactivity.mjs` holds the rule for every collection
17
17
  // either renderer takes in.
18
18
  //
19
+ // THE PROPERTY TAKES A LIST MODEL, THE ATTRIBUTE TAKES JSON (ADR 0122). `model` is an object
20
+ // property over `Gio.ListModel`, held by identity, and an array written to it is a
21
+ // `TypeError`. The ATTRIBUTE is untouched — a `Gio.ListModel` has no attribute spelling — and
22
+ // it fills the element's own `GjsifyOptionList`, whose identity survives an edit so appending
23
+ // one option to `model='[…]'` splices rather than replacing the list and resetting the
24
+ // selection. `readModelWrite` in core is the one door both go through.
25
+ //
19
26
  // THE MODEL IS SPLICED, NOT REBUILT (ADR 0046). `ComboState` reports WHERE the model
20
27
  // changed — `Gio.ListModel::items-changed`, one splice per assignment — and this element
21
28
  // applies exactly that range. Appending one item to a hundred-item model adds ONE
@@ -35,17 +42,16 @@
35
42
  // Properties — the `<gtk-drop-down>` set minus `enableSearch` and `active`, which are that
36
43
  // element's own popover chrome and have no counterpart on a row that opens a native
37
44
  // <select>:
38
- // model — the list model ({ value, label }[]) (get/set). `Adw.ComboRow:model`'s
39
- // own name, per ADR 0034 clause 1; it replaced `options`/`items`, which were two
40
- // further spellings of one GTK property.
41
- // selected — the selected index (get/set). PERMISSIVE, and this is the one row of
42
- // `ComboState`'s contract the two selectors answer differently: an index past the end
43
- // is ACCEPTED here and reads back with an empty `selectedValue`/label, where
44
- // `<gtk-drop-down>` rejects the set outright (gtk-drop-down.ts). Neither is a bug —
45
- // `ComboState.hasIndex` is where the bounds live and why the policy is each
46
- // renderer's. Stated here because a consumer of THIS element reads this file.
45
+ // model — the list model, a `Gio.ListModel` (get/set), held BY IDENTITY.
46
+ // `Adw.ComboRow:model`'s own name, per ADR 0034 clause 1; it replaced
47
+ // `options`/`items`, which were two further spellings of one GTK property. An ARRAY is a
48
+ // `TypeError` naming the type (ADR 0122 § 4) — `options` is the descriptor side.
49
+ // options — the held model's items as `{ value, label }` descriptors (get).
50
+ // selected — the selected index (get/set). An index past the end is IGNORED, as
51
+ // measured in libadwaita and as on `<gtk-drop-down>`; the two selectors no longer
52
+ // answer this differently. `ComboState.hasIndex` is where the bounds live.
47
53
  // selectedValue — the selected option's value ('' when empty) (get/set-by-value).
48
- // selectedItem — the selected option descriptor, or null (get).
54
+ // selectedItem — the model's own selected item, or null (get).
49
55
  // Events:
50
56
  // `notify::selected` (CustomEvent, bubbles, detail = { selected }) — a USER pick.
51
57
  // Deliberately NOT every change, unlike `<gtk-drop-down>`: this row's event has
@@ -58,8 +64,8 @@
58
64
  // title/subtitle text column added to match Adw.ComboRow; the selection state
59
65
  // machine composed from @gjsify/adwaita-core.
60
66
 
61
- import { ComboState, deriveRowLabels, normalizeComboOptions, parseListModel } from '@gjsify/adwaita-core';
62
- import type { AdwComboOption, AdwListItemsChanged, AdwListModelInput } from '@gjsify/adwaita-core';
67
+ import { ComboState, deriveRowLabels, GjsifyOptionList, parseListModel, readModelWrite } from '@gjsify/adwaita-core';
68
+ import type { AdwComboOption, AdwListItemsChanged, ComboWatchableModel } from '@gjsify/adwaita-core';
63
69
 
64
70
  import { applyRowTooltip } from '../row-tooltip.js';
65
71
  import { bindSlottedChildren } from '../slotted-children.js';
@@ -76,34 +82,58 @@ export class AdwComboRow extends TooltipElement {
76
82
  private _subtitleEl!: HTMLSpanElement;
77
83
  /** The headless options list + selectedIndex↔selectedValue state machine (ADR 0004). */
78
84
  private readonly _state = new ComboState();
85
+ /**
86
+ * The list the MARKUP doors fill — the `model` ATTRIBUTE is JSON text and a
87
+ * `<gtk-string-list slot="model">` child is strings, and a real `Gio.ListModel` has no
88
+ * spelling in either (ADR 0122 § 5). Held for the element's whole life so re-parsing the
89
+ * attribute splices the list the element already points at rather than replacing it, which
90
+ * would reset the selection.
91
+ */
92
+ private readonly _authored = GjsifyOptionList.new(null);
79
93
  private _initialized = false;
80
94
 
81
95
  static get observedAttributes() {
82
96
  return ['title', 'subtitle', 'model', 'selected', 'tooltip-text', 'title-lines', 'subtitle-lines'];
83
97
  }
84
98
 
85
- /** The list model (`Adw.ComboRow:model`). Setting it splices the <select> and clamps the selection. */
86
- get model(): AdwComboOption[] {
99
+ /**
100
+ * The list model (`Adw.ComboRow:model`) — the OBJECT that was assigned, by identity, as
101
+ * `row.model === model` is measured to hold in libadwaita (ADR 0122 § 4). The normalised
102
+ * descriptors are {@link options}. Assigning splices the `<select>` and re-runs autoselect.
103
+ */
104
+ get model(): ComboWatchableModel | null {
87
105
  return this._state.model;
88
106
  }
89
107
 
90
- set model(value: AdwListModelInput) {
91
- // `setModel` re-runs autoselect, so an index the new model does not have falls
92
- // back to 0 — the clamp is core's, exactly as `<gtk-drop-down>` gets it.
93
- this._state.setModel(normalizeComboOptions(value));
108
+ set model(value: ComboWatchableModel | string | null) {
109
+ // ONE door for every surface: a real model by identity, `null` for none, a STRING for
110
+ // the `model` attribute (JSON), and a `TypeError` in the property's own words for
111
+ // anything else, an array included.
112
+ this._state.setModel(readModelWrite(value, this._authored));
113
+ }
114
+
115
+ /** The held model's items as `{ value, label }` descriptors. */
116
+ get options(): AdwComboOption[] {
117
+ return this._state.options;
94
118
  }
95
119
 
96
120
  /** `adw_combo_row_set_model`. */
97
- set_model(model: AdwListModelInput | null): void {
98
- this.model = model ?? [];
121
+ set_model(model: ComboWatchableModel | null): void {
122
+ this.model = model;
123
+ }
124
+
125
+ /** `adw_combo_row_get_model`. */
126
+ get_model(): ComboWatchableModel | null {
127
+ return this.model;
99
128
  }
100
129
 
101
130
  /**
102
- * The selected index (Adw.ComboRow:selected). PERMISSIVE: an index past the end is
103
- * accepted and reads back with an empty {@link selectedValue}, mirroring the `guint`
104
- * property that takes any position — where `<gtk-drop-down>` rejects the same set, as
105
- * its own docs promise. `ComboState.hasIndex` owns the bounds and records why the
106
- * POLICY is each renderer's; `COMBO_SELECTION_VECTORS` drives both answers.
131
+ * The selected index (Adw.ComboRow:selected). An index past the end is IGNORED — the
132
+ * selection stays where it was, which is what libadwaita is measured to do (ADR 0122 § 4,
133
+ * `combo-probe2.js`) and what `<gtk-drop-down>` already did. This element used to ACCEPT
134
+ * such a set and read back an empty {@link selectedValue}, the one row of
135
+ * `COMBO_SELECTION_VECTORS` the two selectors answered differently; the permissive half
136
+ * was ours, not GTK's, and it is gone. `ComboState.hasIndex` owns the bounds.
107
137
  */
108
138
  get selected(): number {
109
139
  return this._initialized ? this._state.selectedIndex : parseInt(this.getAttribute('selected') || '0', 10);
@@ -134,19 +164,21 @@ export class AdwComboRow extends TooltipElement {
134
164
  }
135
165
 
136
166
  set selectedValue(value: string) {
137
- const index = this._state.model.findIndex((option) => option.value === value);
167
+ const index = this._state.options.findIndex((option) => option.value === value);
138
168
  if (index >= 0) this.selected = index;
139
169
  }
140
170
 
141
171
  /**
142
- * The selected option descriptor, or `null` (Adw.ComboRow:selected-item).
172
+ * The selected ITEM, or `null` (Adw.ComboRow:selected-item).
143
173
  *
144
- * Read-only, as the GObject property is. `null` and not a blank descriptor: the
145
- * sentinel and an index past the end both address no option, and inventing an empty
146
- * one would make "nothing is selected" indistinguishable from an option with no text.
174
+ * Read-only, as the GObject property is, and the MODEL'S OWN object rather than a
175
+ * descriptor, so the identity comparison a `selected-item` consumer writes holds (ADR
176
+ * 0122 § 4). `null` and not a blank item: the sentinel and an index past the end both
177
+ * address no option, and inventing an empty one would make "nothing is selected"
178
+ * indistinguishable from an option with no text.
147
179
  */
148
- get selectedItem(): AdwComboOption | null {
149
- return this._state.model[this._state.selectedIndex] ?? null;
180
+ get selectedItem(): unknown {
181
+ return this._state.selectedItem;
150
182
  }
151
183
 
152
184
  connectedCallback() {
@@ -177,12 +209,26 @@ export class AdwComboRow extends TooltipElement {
177
209
  // A `<gtk-string-list slot="model">` child is the list a `.blp` authored (ADR 0072),
178
210
  // consumed here BEFORE the attribute seed below, so the seed sees a model and leaves
179
211
  // it alone, and before the selection, so an authored index lands on the real list.
180
- bindSlottedChildren(this, [stringListSlot((model) => this._state.setModel(model))]).install(
181
- text,
182
- this._valueEl,
183
- this._select,
184
- );
185
- if (this._state.count === 0) this._state.setModel(parseListModel(this.getAttribute('model')));
212
+ // The slot hands over DESCRIPTORS, which the authored list adopts — the same list the
213
+ // `model` attribute fills, because the two are one markup door and an author who wrote
214
+ // both should not get two lists.
215
+ bindSlottedChildren(this, [
216
+ stringListSlot((options) => {
217
+ this._authored.reconcile(options);
218
+ this._state.setModel(this._authored);
219
+ }),
220
+ ]).install(text, this._valueEl, this._select);
221
+ // Two tests, and both are needed. `model === null` and no longer "holds no items" is
222
+ // the test for "the slot above did not already set it": a model is an object now, so
223
+ // an EMPTY one is a model the author meant and the count test would have thrown it
224
+ // away. The ATTRIBUTE'S PRESENCE is the test for whether to seed at all — a fresh
225
+ // widget with no authored model has `model === null`, as GTK's does, and seeding the
226
+ // empty authored list unconditionally would hand every unconfigured row a model.
227
+ const authoredModel = this.getAttribute('model');
228
+ if (this._state.model === null && authoredModel !== null) {
229
+ this._authored.reconcile(parseListModel(authoredModel));
230
+ this._state.setModel(this._authored);
231
+ }
186
232
  this._state.setSelectedIndex(parseInt(this.getAttribute('selected') || '0', 10));
187
233
 
188
234
  // WHERE the model changed — applied before the selection change below, which is
@@ -218,7 +264,7 @@ export class AdwComboRow extends TooltipElement {
218
264
  attributeChangedCallback(name: string, _old: string | null, value: string | null) {
219
265
  if (!this._initialized) return;
220
266
  if (name === 'model') {
221
- this._state.setModel(parseListModel(value));
267
+ this.model = value;
222
268
  } else if (name === 'selected') {
223
269
  // A programmatic set — the core state notifies `interactive: false`,
224
270
  // so the subscriber re-syncs the display without emitting an event.
@@ -257,11 +303,11 @@ export class AdwComboRow extends TooltipElement {
257
303
  if (!this._initialized) return;
258
304
  for (let i = 0; i < change.removed; i++) this._select.remove(change.position);
259
305
  const before = this._select.options[change.position] ?? null;
260
- // Read ONCE: `model` hands back a copy (ADR 0046), so reading it per item would make
306
+ // Read ONCE: `options` hands back a copy (ADR 0046), so reading it per item would make
261
307
  // a splice of n items O(n²) instead of the O(n) the splice is.
262
- const model = this._state.model;
308
+ const options = this._state.options;
263
309
  for (let i = 0; i < change.added; i++) {
264
- const option = model[change.position + i];
310
+ const option = options[change.position + i];
265
311
  if (option) this._select.insertBefore(this._createOption(option), before);
266
312
  }
267
313
  }
@@ -274,7 +320,7 @@ export class AdwComboRow extends TooltipElement {
274
320
  */
275
321
  private _renderOptions(): void {
276
322
  if (!this._initialized) return;
277
- this._select.replaceChildren(...this._state.model.map((option) => this._createOption(option)));
323
+ this._select.replaceChildren(...this._state.options.map((option) => this._createOption(option)));
278
324
  this._select.selectedIndex = this._state.selectedIndex;
279
325
  this._valueEl.textContent = this._state.selectedLabel;
280
326
  this._syncChooser();
@@ -32,16 +32,24 @@
32
32
  // shifted a row would have selected the wrong item. The handler now asks the list where
33
33
  // the row IS.
34
34
  //
35
+ // THE `model` PROPERTY IS A REAL `Gio.ListModel` (ADR 0122 § 4), held by identity, and an
36
+ // array written to it is a `TypeError` in GJS's own words. The ARRAY spellings are the
37
+ // MARKUP doors — the JSON attribute and the `<gtk-string-list slot="model">` child — and they
38
+ // fill one list this element owns for its whole life, so re-parsing the attribute splices
39
+ // that list rather than replacing it. `options` is where the normalised `{ value, label }`
40
+ // descriptors moved; everything that draws a row reads them from there.
41
+ //
35
42
  // Attributes:
36
43
  // model — JSON array: `["a","b"]` or `[{"value":"a","label":"A"}]`.
37
44
  // selected — the selected index (number).
38
45
  // enable-search — boolean; show a filtering search entry in the popover.
39
46
  // disabled — boolean; a disabled dropdown does not open.
40
47
  // Properties (mirroring Gtk.DropDown):
41
- // model — the list model ({ value, label }[]) (get/set).
48
+ // model — the list model, a real `Gio.ListModel` or null (get/set).
49
+ // options — the model's items as { value, label } descriptors (get).
42
50
  // selected — the selected index (get/set; bounds-guarded).
43
51
  // selectedValue — the selected option's value ('' when empty) (get/set-by-value).
44
- // selectedItem — the selected option descriptor (or null).
52
+ // selectedItem — the model's own selected item, or null (get).
45
53
  // enableSearch — whether the search entry is shown (get/set).
46
54
  // active — whether the popover is open (get).
47
55
  // Events:
@@ -67,8 +75,8 @@
67
75
  import './gtk-popover.js';
68
76
  import type { GtkPopover } from './gtk-popover.js';
69
77
 
70
- import { ComboState, normalizeComboOptions, parseListModel } from '@gjsify/adwaita-core';
71
- import type { AdwComboOption, AdwListItemsChanged, AdwListModelInput } from '@gjsify/adwaita-core';
78
+ import { ComboState, GjsifyOptionList, parseListModel, readModelWrite } from '@gjsify/adwaita-core';
79
+ import type { AdwComboOption, AdwListItemsChanged, ComboWatchableModel } from '@gjsify/adwaita-core';
72
80
 
73
81
  import { bindSlottedChildren } from '../slotted-children.js';
74
82
  import type { DispatchedSignals } from '../signals.js';
@@ -99,6 +107,14 @@ export class GtkDropDown extends WidgetElement {
99
107
  private _itemButtons: HTMLButtonElement[] = [];
100
108
  /** The headless options list + selectedIndex↔selectedValue machine (ADR 0004). */
101
109
  private readonly _state = new ComboState();
110
+ /**
111
+ * The list the MARKUP doors fill — the `model` ATTRIBUTE is JSON text and a
112
+ * `<gtk-string-list slot="model">` child is strings, and a real `Gio.ListModel` has no
113
+ * spelling in either (ADR 0122 § 5). Held for the element's whole life so re-parsing the
114
+ * attribute splices the list the element already points at rather than replacing it, which
115
+ * would reset the selection.
116
+ */
117
+ private readonly _authored = GjsifyOptionList.new(null);
102
118
  private _initialized = false;
103
119
  private _typeAheadBuffer = '';
104
120
  private _typeAheadTimer: ReturnType<typeof setTimeout> | null = null;
@@ -107,15 +123,35 @@ export class GtkDropDown extends WidgetElement {
107
123
  return ['model', 'selected', 'enable-search', 'disabled'];
108
124
  }
109
125
 
110
- /** The list model (`Gtk.DropDown:model`). Setting it splices the popover and clamps the selection. */
111
- get model(): GtkDropDownOption[] {
126
+ /**
127
+ * The list model (`Gtk.DropDown:model`) — the OBJECT that was assigned, by identity, as
128
+ * `dropdown.model === model` is measured to hold in GTK (ADR 0122 § 4). The normalised
129
+ * descriptors are {@link options}. Assigning splices the popover and re-runs autoselect.
130
+ */
131
+ get model(): ComboWatchableModel | null {
112
132
  return this._state.model;
113
133
  }
114
134
 
115
- set model(value: AdwListModelInput) {
116
- // `setModel` resets the index to 0 when it no longer fits — the same
117
- // clamp this element used to spell out.
118
- this._state.setModel(normalizeComboOptions(value));
135
+ set model(value: ComboWatchableModel | string | null) {
136
+ // ONE door for every surface: a real model by identity, `null` for none, a STRING for
137
+ // the `model` attribute (JSON), and a `TypeError` in the property's own words for
138
+ // anything else, an array included.
139
+ this._state.setModel(readModelWrite(value, this._authored));
140
+ }
141
+
142
+ /** The held model's items as `{ value, label }` descriptors. */
143
+ get options(): AdwComboOption[] {
144
+ return this._state.options;
145
+ }
146
+
147
+ /** `gtk_drop_down_set_model`. */
148
+ set_model(model: ComboWatchableModel | null): void {
149
+ this.model = model;
150
+ }
151
+
152
+ /** `gtk_drop_down_get_model`. */
153
+ get_model(): ComboWatchableModel | null {
154
+ return this.model;
119
155
  }
120
156
 
121
157
  /** The selected index. Out-of-range values are ignored (Gtk.DropDown:selected). */
@@ -143,12 +179,23 @@ export class GtkDropDown extends WidgetElement {
143
179
  }
144
180
 
145
181
  set selectedValue(value: string) {
146
- const index = this._state.model.findIndex((o) => o.value === value);
182
+ const index = this._state.options.findIndex((o) => o.value === value);
147
183
  if (index >= 0) this._selectIndex(index);
148
184
  }
149
185
 
150
- get selectedItem(): GtkDropDownOption | null {
151
- return this._state.model[this._state.selectedIndex] ?? null;
186
+ /**
187
+ * The selected ITEM, or `null` (Gtk.DropDown:selected-item).
188
+ *
189
+ * The MODEL'S OWN object rather than a descriptor, so the identity comparison a
190
+ * `selected-item` consumer writes holds (ADR 0122 § 4).
191
+ */
192
+ get selectedItem(): unknown {
193
+ return this._state.selectedItem;
194
+ }
195
+
196
+ /** `gtk_drop_down_get_selected_item`. */
197
+ get_selected_item(): unknown {
198
+ return this.selectedItem;
152
199
  }
153
200
 
154
201
  get enableSearch(): boolean {
@@ -200,15 +247,30 @@ export class GtkDropDown extends WidgetElement {
200
247
  this._popoverEl.appendChild(this._listEl);
201
248
 
202
249
  // A `<gtk-string-list slot="model">` child is the list a `.blp` authored (ADR 0072),
203
- // consumed first so the attribute seed below sees a model and leaves it alone.
204
- bindSlottedChildren(this, [stringListSlot((model) => this._state.setModel(model))]).install(
205
- this._buttonEl,
206
- this._popoverEl,
207
- );
250
+ // consumed first so the attribute seed below sees a model and leaves it alone. The
251
+ // slot hands over DESCRIPTORS, which the authored list adopts — the same list the
252
+ // `model` attribute fills, because the two are one markup door and an author who
253
+ // wrote both should not get two lists.
254
+ bindSlottedChildren(this, [
255
+ stringListSlot((options) => {
256
+ this._authored.reconcile(options);
257
+ this._state.setModel(this._authored);
258
+ }),
259
+ ]).install(this._buttonEl, this._popoverEl);
208
260
 
209
261
  // Seed options BEFORE anything subscribes, so building the initial DOM below is
210
- // not driven by a change notification.
211
- if (this._state.count === 0) this._state.setModel(parseListModel(this.getAttribute('model')));
262
+ // not driven by a change notification. Two tests, and both are needed. `model ===
263
+ // null` and no longer "holds no items" is the test for "the slot above did not
264
+ // already set it": a model is an object now, so an EMPTY one is a model the author
265
+ // meant and the count test would have thrown it away. The ATTRIBUTE'S PRESENCE is
266
+ // the test for whether to seed at all — a fresh widget with no authored model has
267
+ // `model === null`, as GTK's does, and seeding the empty authored list
268
+ // unconditionally would hand every unconfigured drop-down a model.
269
+ const authoredModel = this.getAttribute('model');
270
+ if (this._state.model === null && authoredModel !== null) {
271
+ this._authored.reconcile(parseListModel(authoredModel));
272
+ this._state.setModel(this._authored);
273
+ }
212
274
  const attrSelected = Number.parseInt(this.getAttribute('selected') ?? '', 10);
213
275
  if (this._state.hasIndex(attrSelected)) this._state.setSelectedIndex(attrSelected);
214
276
 
@@ -216,6 +278,17 @@ export class GtkDropDown extends WidgetElement {
216
278
  // assignment reaches the splice rather than a rebuild.
217
279
  this._state.subscribeItems((change) => this._applyItemsChanged(change));
218
280
 
281
+ // THAT the selection moved, for the moves this element did not ask for: a `model`
282
+ // write re-runs autoselect and lands the selection on 0 (ADR 0122 § 4), and an
283
+ // `items-changed` clamp moves it too. Neither goes through `_selectIndex`, and a
284
+ // model of EQUAL shape splices nothing, so `subscribeItems` alone would leave the
285
+ // button reading the old item's label. The DOM half only — `_selectIndex` owns the
286
+ // `notify::` dispatch and the `change` event, so there is still one emitter.
287
+ this._state.subscribe(() => {
288
+ this._updateLabel();
289
+ this._updateSelectedStates();
290
+ });
291
+
219
292
  this._popoverEl.anchor = this._buttonEl;
220
293
  this._popoverEl.subscribe((open) => this._onPopoverToggled(open));
221
294
  // Type-ahead is the ONE key class the shared popover deliberately leaves to the
@@ -230,7 +303,7 @@ export class GtkDropDown extends WidgetElement {
230
303
  if (name === 'model') {
231
304
  // No render call: the splice subscription does the DOM half, so the markup door
232
305
  // and the property door cannot drift into two update paths.
233
- this._state.setModel(parseListModel(value));
306
+ this.model = value;
234
307
  } else if (name === 'selected') {
235
308
  const index = Number.parseInt(value ?? '', 10);
236
309
  if (!Number.isNaN(index)) this._selectIndex(index, { fromAttr: true });
@@ -243,9 +316,9 @@ export class GtkDropDown extends WidgetElement {
243
316
  }
244
317
 
245
318
  private _selectIndex(index: number, opts: { fromAttr?: boolean; fromUser?: boolean } = {}): void {
246
- // The bounds arithmetic is `ComboState`'s; the POLICY of rejecting an
247
- // out-of-range set is this element's published `selected` contract, and
248
- // `<adw-combo-row>` deliberately answers it the other way (`ComboState.hasIndex`).
319
+ // The bounds arithmetic AND the policy are `ComboState`'s: an index the model does
320
+ // not address is ignored silently, measured on both widgets (ADR 0122 § 4). This
321
+ // element and `<adw-combo-row>` once answered it differently; neither does now.
249
322
  if (!this._state.hasIndex(index)) return;
250
323
  // Both core setters return "did it change", which is what gates the notify — no
251
324
  // second copy of the current index is kept here, so `setModel`' own label
@@ -258,7 +331,10 @@ export class GtkDropDown extends WidgetElement {
258
331
  this.setAttribute('selected', String(index));
259
332
  }
260
333
  if (changed) {
261
- const option = this._state.model[index];
334
+ // The MODEL'S OWN item, not a descriptor: `selected-item` is an identity in GTK
335
+ // (ADR 0122 § 4), so a listener comparing it against the item it put in the model
336
+ // has to match.
337
+ const item = this._state.selectedItem;
262
338
  // `notify::selected` mirrors GObject property-notify: EVERY change,
263
339
  // programmatic included.
264
340
  this.dispatchEvent(new CustomEvent('notify::selected', { bubbles: true, detail: { selected: index } }));
@@ -266,7 +342,7 @@ export class GtkDropDown extends WidgetElement {
266
342
  this.dispatchEvent(
267
343
  new CustomEvent('notify::selected-item', {
268
344
  bubbles: true,
269
- detail: { 'selected-item': option ?? null },
345
+ detail: { 'selected-item': item ?? null },
270
346
  }),
271
347
  );
272
348
  // `change` mirrors the DOM <select> contract: ONLY a user-initiated change. A
@@ -276,7 +352,7 @@ export class GtkDropDown extends WidgetElement {
276
352
  this.dispatchEvent(
277
353
  new CustomEvent('change', {
278
354
  bubbles: true,
279
- detail: { index, value: option?.value ?? '', label: option?.label ?? '' },
355
+ detail: { index, value: this._state.selectedValue, label: this._state.selectedLabel },
280
356
  }),
281
357
  );
282
358
  }
@@ -319,7 +395,7 @@ export class GtkDropDown extends WidgetElement {
319
395
  this._typeAheadBuffer += char.toLowerCase();
320
396
  if (this._typeAheadTimer) clearTimeout(this._typeAheadTimer);
321
397
  this._typeAheadTimer = setTimeout(() => (this._typeAheadBuffer = ''), 800);
322
- const match = this._state.model.findIndex((o) => o.label.toLowerCase().startsWith(this._typeAheadBuffer));
398
+ const match = this._state.options.findIndex((o) => o.label.toLowerCase().startsWith(this._typeAheadBuffer));
323
399
  if (match < 0) return;
324
400
  if (this._popoverEl.open) this._itemButtons[match]?.focus();
325
401
  else this._selectIndex(match, { fromUser: true });
@@ -329,9 +405,9 @@ export class GtkDropDown extends WidgetElement {
329
405
  const q = query.trim().toLowerCase();
330
406
  // Hoisted for the reason `_applyItemsChanged` gives: this runs on every keystroke of
331
407
  // the search field, over every row.
332
- const model = this._state.model;
408
+ const options = this._state.options;
333
409
  this._itemButtons.forEach((button, index) => {
334
- const match = q.length === 0 || (model[index]?.label.toLowerCase().includes(q) ?? false);
410
+ const match = q.length === 0 || (options[index]?.label.toLowerCase().includes(q) ?? false);
335
411
  button.hidden = !match;
336
412
  });
337
413
  }
@@ -380,7 +456,7 @@ export class GtkDropDown extends WidgetElement {
380
456
  if (existing) existing.remove();
381
457
  }
382
458
 
383
- for (const option of this._state.model) {
459
+ for (const option of this._state.options) {
384
460
  const item = this._createItem(option);
385
461
  this._listEl.appendChild(item);
386
462
  this._itemButtons.push(item);
@@ -439,11 +515,11 @@ export class GtkDropDown extends WidgetElement {
439
515
  }
440
516
  const before = this._itemButtons[change.position] ?? null;
441
517
  const inserted: HTMLButtonElement[] = [];
442
- // Read ONCE: `model` hands back a copy (ADR 0046), so reading it per item would make
443
- // a splice of n items O(n²) instead of the O(n) the splice is.
444
- const model = this._state.model;
518
+ // Read ONCE: `options` normalises every item on each call (ADR 0046), so reading it
519
+ // per item would make a splice of n items O(n²) instead of the O(n) the splice is.
520
+ const options = this._state.options;
445
521
  for (let i = 0; i < change.added; i++) {
446
- const option = model[change.position + i];
522
+ const option = options[change.position + i];
447
523
  if (!option) continue;
448
524
  const item = this._createItem(option);
449
525
  this._listEl.insertBefore(item, before);
@@ -178,12 +178,14 @@ export class GtkPageSetupUnixDialog extends AdwDialog {
178
178
  */
179
179
  set pageSetup(value: PageSetup) {
180
180
  const index = this._setups.findIndex((setup) => pageSetupEquals(setup, value));
181
- if (index < 0) {
182
- this._setups.push(value);
183
- this._paperEl.selected = this._setups.length - 1;
184
- } else if (this._paperEl.selected !== index) {
185
- this._paperEl.selected = index;
186
- }
181
+ if (index < 0) this._setups.push(value);
182
+ // The row list GROWS BEFORE the selection moves. The drop-down refuses an index its
183
+ // model does not address, and a splice that lands at or before the selection shifts
184
+ // it rather than leaving it standing (ADR 0122 § 4). Selecting first worked only
185
+ // while the appended row happened to fall where `Manage Custom Sizes…` already was.
186
+ if (this._built) this._renderSetup();
187
+ const wanted = index < 0 ? this._setups.length - 1 : index;
188
+ if (this._paperEl.selected !== wanted) this._paperEl.selected = wanted;
187
189
  this._setOrientation(value.orientation);
188
190
  if (this._built) this._renderSetup();
189
191
  }
@@ -22,6 +22,7 @@ import {
22
22
  dialogFailedError,
23
23
  dismissedError,
24
24
  fileFilterAccepts,
25
+ GjsifyOptionList,
25
26
  GLibError,
26
27
  registerFileChooserDriver,
27
28
  type FileChooserDriver,
@@ -291,7 +292,7 @@ export function presentOpfsDialog(
291
292
  let dropDown: GtkDropDown | null = null;
292
293
  if (filters.length > 0) {
293
294
  dropDown = el('gtk-drop-down') as GtkDropDown;
294
- dropDown.model = filters.map((f, i) => f.name ?? `Filter ${i + 1}`);
295
+ dropDown.model = GjsifyOptionList.new(filters.map((f, i) => f.name ?? `Filter ${i + 1}`));
295
296
  dropDown.selected = Math.max(initial, 0);
296
297
  bottom.append(dropDown);
297
298
  }
@@ -91,6 +91,45 @@ GObject.registerClass(
91
91
  GoWebSensitive as never,
92
92
  );
93
93
 
94
+ const SiblingsTemplate = tree(`template $GoWebSiblings : Adw.Bin {
95
+ child: Gtk.Box {
96
+ Gtk.Label caption { label: bind peer.title; }
97
+ };
98
+ }
99
+
100
+ Adw.AlertDialog unsavedChangesDialog { heading: "Save changes?"; }
101
+
102
+ $GoWebPeer peer { }`);
103
+
104
+ class GoWebPeer extends Adw.Bin {
105
+ declare title: string;
106
+ }
107
+ GObject.registerClass(
108
+ {
109
+ GTypeName: 'GoWebPeer',
110
+ Properties: { title: GObject.ParamSpec.string('title', '', '', GObject.ParamFlags.READWRITE, 'first') },
111
+ },
112
+ GoWebPeer as never,
113
+ );
114
+
115
+ class GoWebSiblings extends Adw.Bin {
116
+ declare _unsavedChangesDialog: HTMLElement & { heading: string };
117
+ declare _peer: GoWebPeer;
118
+ declare _caption: HTMLElement & { label: string };
119
+ constructor(params?: Record<string, unknown>) {
120
+ // @ts-expect-error the element's own constructor takes none; the registered class's takes the params
121
+ super(params);
122
+ }
123
+ }
124
+ GObject.registerClass(
125
+ {
126
+ GTypeName: 'GoWebSiblings',
127
+ Template: SiblingsTemplate,
128
+ InternalChildren: ['unsavedChangesDialog', 'peer', 'caption'],
129
+ },
130
+ GoWebSiblings as never,
131
+ );
132
+
94
133
  export const GObjectDoorTest = async () => {
95
134
  await driveGObjectVectors(subject, { describe, it, expect }, GOBJECT_VECTORS);
96
135
  await describe('adwaita-web: GObject.registerClass end to end (ADR 0096)', async () => {
@@ -98,6 +137,24 @@ export const GObjectDoorTest = async () => {
98
137
  expect(tagForTypeName('Hexdump')).toBe('gjsify-hexdump');
99
138
  expect(customElements.get('gjsify-go-web-hexdump')).toBe(GoWebHexdump);
100
139
  });
140
+ await it('reaches the objects written beside the template through InternalChildren, outside its children', () => {
141
+ const el = new GoWebSiblings();
142
+ expect(el._unsavedChangesDialog.localName).toBe('adw-alert-dialog');
143
+ expect(el._peer instanceof GoWebPeer).toBe(true);
144
+ document.body.append(el);
145
+ try {
146
+ // Independent objects, as in GTK: neither is a child of the template widget.
147
+ expect(el.contains(el._unsavedChangesDialog)).toBe(false);
148
+ expect(el.contains(el._peer)).toBe(false);
149
+ expect(el.contains(el._caption)).toBe(true);
150
+ // A template child names a sibling by id, and follows it.
151
+ expect(el._caption.label).toBe('first');
152
+ el._peer.title = 'second';
153
+ expect(el._caption.label).toBe('second');
154
+ } finally {
155
+ el.remove();
156
+ }
157
+ });
101
158
  await it('refuses a second class under the same tag', () => {
102
159
  expect(() =>
103
160
  GObject.registerClass({ GTypeName: 'GoWebHexdump' }, class extends Adw.Bin {} as never),