@gjsify/adwaita-web 0.47.0 → 0.49.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.
Files changed (42) hide show
  1. package/README.md +3 -3
  2. package/dist/adwaita-web.css +62 -0
  3. package/dist/adwaita-web.css.map +1 -1
  4. package/lib/types/elements/adw-combo-row.d.ts +56 -0
  5. package/lib/types/elements/adw-spin-row.d.ts +11 -1
  6. package/lib/types/elements/adw-split-button.d.ts +43 -3
  7. package/lib/types/elements/adw-tab-view.d.ts +23 -5
  8. package/lib/types/elements/adw-view-stack.d.ts +17 -2
  9. package/lib/types/elements/gtk-drop-down.d.ts +22 -7
  10. package/lib/types/elements/gtk-menu-button.d.ts +43 -26
  11. package/lib/types/elements/gtk-popover.d.ts +14 -1
  12. package/lib/types/elements/popover-menu.d.ts +99 -0
  13. package/lib/types/index.d.ts +1 -1
  14. package/lib/types/shared-trees.spec.d.ts +2 -0
  15. package/lib/types/styles.generated.d.ts +1 -1
  16. package/package.json +6 -6
  17. package/scss/_popover.scss +99 -0
  18. package/src/adw-avatar.spec.ts +17 -0
  19. package/src/adw-row-state.spec.ts +335 -17
  20. package/src/adw-tab-view.spec.ts +8 -5
  21. package/src/elements/adw-avatar.ts +11 -3
  22. package/src/elements/adw-combo-row.ts +161 -22
  23. package/src/elements/adw-spin-row.ts +49 -28
  24. package/src/elements/adw-split-button.ts +117 -39
  25. package/src/elements/adw-tab-view.ts +42 -22
  26. package/src/elements/adw-view-stack.ts +20 -4
  27. package/src/elements/adw-view-switcher-bar.ts +6 -3
  28. package/src/elements/gtk-drop-down.ts +109 -66
  29. package/src/elements/gtk-menu-button.ts +112 -88
  30. package/src/elements/gtk-popover.ts +17 -2
  31. package/src/elements/popover-menu.ts +366 -0
  32. package/src/gtk-drop-down.spec.ts +117 -25
  33. package/src/gtk-image.spec.ts +3 -3
  34. package/src/gtk-menu-button.spec.ts +165 -11
  35. package/src/index.ts +14 -1
  36. package/src/keyboard-operable.spec.ts +170 -1
  37. package/src/shared-trees.spec.ts +151 -0
  38. package/src/split-button.spec.ts +36 -22
  39. package/src/styles.generated.ts +1 -1
  40. package/src/tab-view.spec.ts +83 -15
  41. package/src/test.browser.mts +2 -0
  42. package/src/view-stack.spec.ts +5 -3
@@ -106,11 +106,11 @@ export class AdwTabView extends HTMLElement {
106
106
  private readonly _hovered = new Set<string>();
107
107
  private _initialized = false;
108
108
  private _generatedIds = 0;
109
- /** Guards the `selected` attribute reflection against re-entering the model. */
109
+ /** Guards the `selected-page` attribute reflection against re-entering the model. */
110
110
  private _reflecting = false;
111
111
 
112
112
  static get observedAttributes() {
113
- return ['selected', 'autohide', 'expand-tabs', 'no-close'];
113
+ return ['selected-page', 'autohide', 'expand-tabs', 'no-close'];
114
114
  }
115
115
 
116
116
  constructor() {
@@ -131,13 +131,26 @@ export class AdwTabView extends HTMLElement {
131
131
  this._state.subscribe((change) => this._onSelectionChange(change));
132
132
  }
133
133
 
134
- /** Zero-based index of the visible page, `-1` when the view is empty. */
135
- get selected(): number {
136
- return this._state.selectedIndex;
134
+ /**
135
+ * The selected page (`Adw.TabView:selected-page`), `null` when the view is empty.
136
+ *
137
+ * THE PAGE, NOT ITS POSITION (ADR 0048). Its position is {@link selectedIndex}, which
138
+ * stays a read-only report; `Adw.TabView` selects by object, and the model has held
139
+ * that object all along.
140
+ */
141
+ get selectedPage(): AdwTabViewPage | null {
142
+ return this._state.selectedPage;
137
143
  }
138
144
 
139
- set selected(value: number) {
140
- this._state.selectNthPage(value);
145
+ set selectedPage(page: AdwTabViewPage | null) {
146
+ // Straight to the core, which owns the page list and therefore owns
147
+ // `page_belongs_to_this_view` — the check an ID cannot answer: an id is unique
148
+ // WITHIN a view, never across two (a declared `page-id` is the author's to repeat
149
+ // and `_nextId` counts per element, so two views both hold a `tab-1`). Measured
150
+ // before the core took the page: `viewA.selectedPage = viewB.pages[2]` moved viewA
151
+ // to index 2 instead of refusing. The refusal carries C's own diagnostic now,
152
+ // where a renderer-side guard could only return in silence.
153
+ this._state.setSelectedPage(page);
141
154
  }
142
155
 
143
156
  /**
@@ -177,8 +190,8 @@ export class AdwTabView extends HTMLElement {
177
190
  this._initialized = true;
178
191
 
179
192
  // Read the declared selection BEFORE adopting anything: the auto-select
180
- // of the first page reflects its own index into this very attribute.
181
- const declaredSelection = this.getAttribute('selected');
193
+ // of the first page reflects its own id into this very attribute.
194
+ const declaredSelection = this.getAttribute('selected-page');
182
195
 
183
196
  // Snapshot the declared pages, then take over the subtree. The elements
184
197
  // themselves become the panels, so they must survive replaceChildren.
@@ -195,12 +208,12 @@ export class AdwTabView extends HTMLElement {
195
208
 
196
209
  attributeChangedCallback(name: string, _old: string | null, _value: string | null) {
197
210
  if (!this._initialized) return;
198
- if (name === 'selected') {
211
+ if (name === 'selected-page') {
199
212
  // The LIVE attribute, not the value captured when the reaction was
200
213
  // queued: reflection writes this attribute, and a custom-element
201
214
  // reaction can run after a later write has already superseded it.
202
215
  if (this._reflecting) return;
203
- const current = this.getAttribute('selected');
216
+ const current = this.getAttribute('selected-page');
204
217
  if (current !== null) this._applySelectedAttribute(current);
205
218
  return;
206
219
  }
@@ -272,8 +285,9 @@ export class AdwTabView extends HTMLElement {
272
285
  return this._state.isClosing(id);
273
286
  }
274
287
 
275
- setSelectedPage(id: string | null): boolean {
276
- return this._state.setSelectedPage(id);
288
+ /** `adw_tab_view_set_selected_page`, which takes the PAGE; an id is accepted too. */
289
+ setSelectedPage(page: AdwTabViewPage | string | null): boolean {
290
+ return this._state.setSelectedPage(page);
277
291
  }
278
292
 
279
293
  selectNthPage(n: number): boolean {
@@ -724,19 +738,25 @@ export class AdwTabView extends HTMLElement {
724
738
 
725
739
  private _reflectSelected(): void {
726
740
  this._reflecting = true;
727
- const index = this._state.selectedIndex;
728
- if (index < 0) this.removeAttribute('selected');
729
- else if (this.getAttribute('selected') !== String(index)) this.setAttribute('selected', String(index));
741
+ const id = this._state.selectedId;
742
+ if (id === null) this.removeAttribute('selected-page');
743
+ else if (this.getAttribute('selected-page') !== id) this.setAttribute('selected-page', id);
730
744
  this._reflecting = false;
731
745
  }
732
746
 
747
+ /**
748
+ * `selected-page="<page id>"` — the markup half of {@link selectedPage} (ADR 0048).
749
+ *
750
+ * The id is the one `<adw-tab-page page-id>` declares; a page that declares none gets
751
+ * a generated one, which an author cannot predict and therefore cannot select by. That
752
+ * is the same bargain `<adw-view-stack visible-child-name>` already makes for a page
753
+ * with no name, and it is why `page-id` exists.
754
+ *
755
+ * An id no page holds is IGNORED rather than treated as the first page — libadwaita
756
+ * refuses a foreign `AdwTabPage` outright, and `setSelectedPage` is the refusal here.
757
+ */
733
758
  private _applySelectedAttribute(value: string): void {
734
- const index = Number.parseInt(value, 10);
735
- // An unparseable value is IGNORED rather than treated as 0, and an out-of-range
736
- // one is refused rather than clamped: libadwaita refuses both, so
737
- // `selected="oops"` must not select the first page nor `selected="99"` the last.
738
- if (Number.isNaN(index)) return;
739
- this._state.selectNthPage(index);
759
+ this._state.setSelectedPage(value);
740
760
  }
741
761
 
742
762
  /**
@@ -26,7 +26,8 @@
26
26
  // Properties (mirroring Adw.ViewStack):
27
27
  // pages — readonly page descriptors ({ name, title, icon, content, visible }).
28
28
  // visibleChildName — name of the visible page ('' when nothing is selected).
29
- // visibleChildIndex — zero-based index of the visible page, -1 for none (guarded).
29
+ // visibleChildIndex — READ-ONLY report: position of the visible page, -1 for none.
30
+ // Selecting is `visibleChildName` (ADR 0048).
30
31
  // visibleChild — the visible page's content element (or null).
31
32
  // Methods:
32
33
  // add(content, name, title?, icon?) — append a page; returns its descriptor.
@@ -70,13 +71,28 @@ export class AdwViewStack extends HTMLElement {
70
71
  return this._state.pages;
71
72
  }
72
73
 
73
- /** Zero-based index of the visible page, `-1` when nothing is selected. */
74
+ /**
75
+ * Zero-based position of the visible page, `-1` when nothing is selected.
76
+ *
77
+ * A REPORT, NOT A DOOR (ADR 0048). `Adw.ViewStack` selects by NAME, and an ordinal is
78
+ * derived — an insertion before the visible page increments it, a reorder rewrites it,
79
+ * a removal decrements it. Selecting is {@link visibleChildName}.
80
+ */
74
81
  get visibleChildIndex(): number {
75
82
  return this._state.visibleIndex;
76
83
  }
77
84
 
78
- set visibleChildIndex(value: number) {
79
- this._state.setVisibleIndex(value);
85
+ /**
86
+ * Show the page at position `n`. Returns whether the selection moved.
87
+ *
88
+ * THE ORDINAL IS A CALL, NOT A PROPERTY (ADR 0048): it resolves against the page list
89
+ * as it is now and keeps nothing, where a held index comes to mean another page after
90
+ * an insert, a reorder or a removal. Port-owned — `refs/libadwaita` gives
91
+ * `AdwViewStack` no ordinal API — and named for the tab view's existing
92
+ * `selectNthPage`. The guard on `n` is the core's and is pinned by the shared vectors.
93
+ */
94
+ selectNthPage(n: number): boolean {
95
+ return this._state.setVisibleIndex(n);
80
96
  }
81
97
 
82
98
  get visibleChildName(): string {
@@ -99,8 +99,10 @@ export class AdwViewSwitcherBar extends HTMLElement {
99
99
  const stack = this._stack;
100
100
  if (!stack) return;
101
101
  const index = Number(item.dataset.pageIndex);
102
- // Re-assigning the current index would re-run the stack's transition.
103
- if (stack.visibleChildIndex !== index) stack.visibleChildIndex = index;
102
+ // The roving-focus list is ordinal and the stack resolves it (ADR 0048).
103
+ // Re-selecting the current page would re-run the stack's transition, which
104
+ // is why the position is compared before the call.
105
+ if (stack.visibleChildIndex !== index) stack.selectNthPage(index);
104
106
  },
105
107
  });
106
108
  }
@@ -238,7 +240,8 @@ export class AdwViewSwitcherBar extends HTMLElement {
238
240
 
239
241
  button.append(icon, label, indicator);
240
242
  button.addEventListener('click', () => {
241
- if (this._stack) this._stack.visibleChildIndex = pageIndex;
243
+ // Ordinal here, resolved by the stack (ADR 0048) — as in `select` above.
244
+ this._stack?.selectNthPage(pageIndex);
242
245
  });
243
246
 
244
247
  return { button, icon, label, indicator };
@@ -5,9 +5,10 @@
5
5
  // <adw-combo-row> (which embeds the same idea inside a boxed-list row) — reach
6
6
  // for this one when you need a bare dropdown, not a row.
7
7
  //
8
- // Options are `{ value, label }` pairs (or plain strings, where the string is
9
- // both value and label), supplied via the `options` / `items` property or a
10
- // JSON `options` attribute. Optional `enable-search` puts a search entry atop
8
+ // The model is `{ value, label }` pairs (or plain strings, where the string is
9
+ // both value and label), supplied via the `model` property or a JSON `model`
10
+ // attribute — `Gtk.DropDown:model`'s own name (ADR 0034 clause 1, ADR 0046), which
11
+ // replaced the two spellings `options`/`items`. Optional `enable-search` puts a search entry atop
11
12
  // the popover for long lists. The popover is dismissed on an outside click or
12
13
  // Escape and supports arrow-key navigation, Home/End, Enter to pick, and
13
14
  // type-ahead (typing jumps to the next matching option).
@@ -23,13 +24,21 @@
23
24
  // copy is how ArrowUp from an unfocused list lands on the SECOND-TO-LAST option, which
24
25
  // `enable-search` hits on every open because it focuses the entry.
25
26
  //
27
+ // THE MODEL IS SPLICED, NOT REBUILT (ADR 0046). `ComboState` reports WHERE the model
28
+ // changed — `Gio.ListModel::items-changed`, one splice per assignment — so replacing a
29
+ // model that gained one item inserts ONE row button and leaves the rest, with their focus
30
+ // and their scroll position, alone. It also closed a latent defect the rebuild hid: each
31
+ // row's click handler used to close over the index it was built at, so any splice that
32
+ // shifted a row would have selected the wrong item. The handler now asks the list where
33
+ // the row IS.
34
+ //
26
35
  // Attributes:
27
- // options / items — JSON array: `["a","b"]` or `[{"value":"a","label":"A"}]`.
36
+ // model — JSON array: `["a","b"]` or `[{"value":"a","label":"A"}]`.
28
37
  // selected — the selected index (number).
29
38
  // enable-search — boolean; show a filtering search entry in the popover.
30
39
  // disabled — boolean; a disabled dropdown does not open.
31
40
  // Properties (mirroring Gtk.DropDown):
32
- // options / items — the option list ({ value, label }[]) (get/set).
41
+ // model — the list model ({ value, label }[]) (get/set).
33
42
  // selected — the selected index (get/set; bounds-guarded).
34
43
  // selectedValue — the selected option's value ('' when empty) (get/set-by-value).
35
44
  // selectedItem — the selected option descriptor (or null).
@@ -56,8 +65,8 @@
56
65
  import './gtk-popover.js';
57
66
  import type { GtkPopover } from './gtk-popover.js';
58
67
 
59
- import { ComboState, normalizeComboOptions } from '@gjsify/adwaita-core';
60
- import type { AdwComboOption, AdwComboOptionInput } from '@gjsify/adwaita-core';
68
+ import { ComboState, normalizeComboOptions, parseListModel } from '@gjsify/adwaita-core';
69
+ import type { AdwComboOption, AdwListItemsChanged, AdwListModelInput } from '@gjsify/adwaita-core';
61
70
 
62
71
  import { createGtkImage } from './gtk-image.js';
63
72
 
@@ -82,27 +91,18 @@ export class GtkDropDown extends HTMLElement {
82
91
  private _typeAheadTimer: ReturnType<typeof setTimeout> | null = null;
83
92
 
84
93
  static get observedAttributes() {
85
- return ['options', 'items', 'selected', 'enable-search', 'disabled'];
94
+ return ['model', 'selected', 'enable-search', 'disabled'];
86
95
  }
87
96
 
88
- /** The options list. Setting it rebuilds the popover and clamps the selection. */
89
- get options(): GtkDropDownOption[] {
90
- return this._state.options;
97
+ /** The list model (`Gtk.DropDown:model`). Setting it splices the popover and clamps the selection. */
98
+ get model(): GtkDropDownOption[] {
99
+ return this._state.model;
91
100
  }
92
101
 
93
- set options(value: ReadonlyArray<AdwComboOptionInput>) {
94
- // `setOptions` resets the index to 0 when it no longer fits — the same
102
+ set model(value: AdwListModelInput) {
103
+ // `setModel` resets the index to 0 when it no longer fits — the same
95
104
  // clamp this element used to spell out.
96
- this._state.setOptions(normalizeComboOptions(value));
97
- if (this._initialized) this._renderAll();
98
- }
99
-
100
- get items(): GtkDropDownOption[] {
101
- return this._state.options;
102
- }
103
-
104
- set items(value: ReadonlyArray<AdwComboOptionInput>) {
105
- this.options = value;
105
+ this._state.setModel(normalizeComboOptions(value));
106
106
  }
107
107
 
108
108
  /** The selected index. Out-of-range values are ignored (Gtk.DropDown:selected). */
@@ -120,12 +120,12 @@ export class GtkDropDown extends HTMLElement {
120
120
  }
121
121
 
122
122
  set selectedValue(value: string) {
123
- const index = this._state.options.findIndex((o) => o.value === value);
123
+ const index = this._state.model.findIndex((o) => o.value === value);
124
124
  if (index >= 0) this._selectIndex(index);
125
125
  }
126
126
 
127
127
  get selectedItem(): GtkDropDownOption | null {
128
- return this._state.options[this._state.selectedIndex] ?? null;
128
+ return this._state.model[this._state.selectedIndex] ?? null;
129
129
  }
130
130
 
131
131
  get enableSearch(): boolean {
@@ -178,10 +178,14 @@ export class GtkDropDown extends HTMLElement {
178
178
 
179
179
  // Seed options BEFORE anything subscribes, so building the initial DOM below is
180
180
  // not driven by a change notification.
181
- if (this._state.count === 0) this._state.setOptions(this._parseOptionsAttr());
181
+ if (this._state.count === 0) this._state.setModel(parseListModel(this.getAttribute('model')));
182
182
  const attrSelected = Number.parseInt(this.getAttribute('selected') ?? '', 10);
183
183
  if (this._state.hasIndex(attrSelected)) this._state.setSelectedIndex(attrSelected);
184
184
 
185
+ // WHERE the model changed. Subscribed before the first render so every later
186
+ // assignment reaches the splice rather than a rebuild.
187
+ this._state.subscribeItems((change) => this._applyItemsChanged(change));
188
+
185
189
  this.replaceChildren(this._buttonEl, this._popoverEl);
186
190
  this._popoverEl.anchor = this._buttonEl;
187
191
  this._popoverEl.subscribe((open) => this._onPopoverToggled(open));
@@ -194,9 +198,10 @@ export class GtkDropDown extends HTMLElement {
194
198
 
195
199
  attributeChangedCallback(name: string, _old: string | null, value: string | null) {
196
200
  if (!this._initialized || !this._buttonEl) return;
197
- if (name === 'options' || name === 'items') {
198
- this._state.setOptions(this._parseOptionsAttr());
199
- this._renderAll();
201
+ if (name === 'model') {
202
+ // No render call: the splice subscription does the DOM half, so the markup door
203
+ // and the property door cannot drift into two update paths.
204
+ this._state.setModel(parseListModel(value));
200
205
  } else if (name === 'selected') {
201
206
  const index = Number.parseInt(value ?? '', 10);
202
207
  if (!Number.isNaN(index)) this._selectIndex(index, { fromAttr: true });
@@ -208,25 +213,13 @@ export class GtkDropDown extends HTMLElement {
208
213
  }
209
214
  }
210
215
 
211
- private _parseOptionsAttr(): GtkDropDownOption[] {
212
- const raw = this.getAttribute('options') ?? this.getAttribute('items');
213
- if (!raw) return [];
214
- try {
215
- // `normalizeComboOptions` already guards a non-array, so a JSON object or
216
- // scalar yields the empty model rather than a throw.
217
- return normalizeComboOptions(JSON.parse(raw) as AdwComboOptionInput[]);
218
- } catch {
219
- return [];
220
- }
221
- }
222
-
223
216
  private _selectIndex(index: number, opts: { fromAttr?: boolean; fromUser?: boolean } = {}): void {
224
217
  // The bounds arithmetic is `ComboState`'s; the POLICY of rejecting an
225
218
  // out-of-range set is this element's published `selected` contract, and
226
219
  // `<adw-combo-row>` deliberately answers it the other way (`ComboState.hasIndex`).
227
220
  if (!this._state.hasIndex(index)) return;
228
221
  // Both core setters return "did it change", which is what gates the notify — no
229
- // second copy of the current index is kept here, so `setOptions`' own label
222
+ // second copy of the current index is kept here, so `setModel`' own label
230
223
  // re-sync cannot masquerade as one.
231
224
  const changed = opts.fromUser ? this._state.select(index) : this._state.setSelectedIndex(index);
232
225
  this._updateLabel();
@@ -236,7 +229,7 @@ export class GtkDropDown extends HTMLElement {
236
229
  this.setAttribute('selected', String(index));
237
230
  }
238
231
  if (changed) {
239
- const option = this._state.options[index];
232
+ const option = this._state.model[index];
240
233
  // `notify::selected` mirrors GObject property-notify: EVERY change,
241
234
  // programmatic included.
242
235
  this.dispatchEvent(new CustomEvent('notify::selected', { bubbles: true, detail: { selected: index } }));
@@ -290,7 +283,7 @@ export class GtkDropDown extends HTMLElement {
290
283
  this._typeAheadBuffer += char.toLowerCase();
291
284
  if (this._typeAheadTimer) clearTimeout(this._typeAheadTimer);
292
285
  this._typeAheadTimer = setTimeout(() => (this._typeAheadBuffer = ''), 800);
293
- const match = this._state.options.findIndex((o) => o.label.toLowerCase().startsWith(this._typeAheadBuffer));
286
+ const match = this._state.model.findIndex((o) => o.label.toLowerCase().startsWith(this._typeAheadBuffer));
294
287
  if (match < 0) return;
295
288
  if (this._popoverEl.open) this._itemButtons[match]?.focus();
296
289
  else this._selectIndex(match, { fromUser: true });
@@ -298,8 +291,11 @@ export class GtkDropDown extends HTMLElement {
298
291
 
299
292
  private _filter(query: string): void {
300
293
  const q = query.trim().toLowerCase();
294
+ // Hoisted for the reason `_applyItemsChanged` gives: this runs on every keystroke of
295
+ // the search field, over every row.
296
+ const model = this._state.model;
301
297
  this._itemButtons.forEach((button, index) => {
302
- const match = q.length === 0 || (this._state.options[index]?.label.toLowerCase().includes(q) ?? false);
298
+ const match = q.length === 0 || (model[index]?.label.toLowerCase().includes(q) ?? false);
303
299
  button.hidden = !match;
304
300
  });
305
301
  }
@@ -348,27 +344,8 @@ export class GtkDropDown extends HTMLElement {
348
344
  if (existing) existing.remove();
349
345
  }
350
346
 
351
- for (const [index, option] of this._state.options.entries()) {
352
- const item = document.createElement('button');
353
- item.type = 'button';
354
- item.className = 'adw-popover-item adw-drop-down-item';
355
- item.setAttribute('role', 'option');
356
- item.tabIndex = -1;
357
-
358
- const label = document.createElement('span');
359
- label.className = 'adw-drop-down-item-label';
360
- label.textContent = option.label;
361
-
362
- const check = document.createElement('span');
363
- check.className = 'adw-drop-down-item-check';
364
- check.setAttribute('aria-hidden', 'true');
365
-
366
- item.append(label, check);
367
- item.addEventListener('click', () => {
368
- this._selectIndex(index, { fromUser: true });
369
- this._popoverEl.popdown();
370
- this._buttonEl.focus();
371
- });
347
+ for (const option of this._state.model) {
348
+ const item = this._createItem(option);
372
349
  this._listEl.appendChild(item);
373
350
  this._itemButtons.push(item);
374
351
  }
@@ -376,6 +353,72 @@ export class GtkDropDown extends HTMLElement {
376
353
  this._updateSelectedStates();
377
354
  if (this._state.count === 0) this._popoverEl.popdown();
378
355
  }
356
+
357
+ /**
358
+ * One row button.
359
+ *
360
+ * The click handler reads the row's CURRENT position out of `_itemButtons` instead of
361
+ * closing over the index it was built at. Under a full rebuild the two were always
362
+ * equal, which is why the closure survived; under a splice they are not, and a row
363
+ * that shifted by one would have selected its neighbour.
364
+ */
365
+ private _createItem(option: GtkDropDownOption): HTMLButtonElement {
366
+ const item = document.createElement('button');
367
+ item.type = 'button';
368
+ item.className = 'adw-popover-item adw-drop-down-item';
369
+ item.setAttribute('role', 'option');
370
+ item.tabIndex = -1;
371
+
372
+ const label = document.createElement('span');
373
+ label.className = 'adw-drop-down-item-label';
374
+ label.textContent = option.label;
375
+
376
+ const check = document.createElement('span');
377
+ check.className = 'adw-drop-down-item-check';
378
+ check.setAttribute('aria-hidden', 'true');
379
+
380
+ item.append(label, check);
381
+ item.addEventListener('click', () => {
382
+ const index = this._itemButtons.indexOf(item);
383
+ if (index < 0) return;
384
+ this._selectIndex(index, { fromUser: true });
385
+ this._popoverEl.popdown();
386
+ this._buttonEl.focus();
387
+ });
388
+ return item;
389
+ }
390
+
391
+ /**
392
+ * Apply one `items-changed` to the row list — remove `removed` buttons at `position`,
393
+ * insert `added` fresh ones there, leave every other row (and its focus) standing.
394
+ *
395
+ * It owns everything a model replacement has to refresh, so the property door and the
396
+ * attribute door share one update path: the button label, the per-row selected state,
397
+ * and popping the popover down when the model empties under it.
398
+ */
399
+ private _applyItemsChanged(change: AdwListItemsChanged): void {
400
+ if (!this._initialized) return;
401
+ for (const removedButton of this._itemButtons.splice(change.position, change.removed)) {
402
+ removedButton.remove();
403
+ }
404
+ const before = this._itemButtons[change.position] ?? null;
405
+ const inserted: HTMLButtonElement[] = [];
406
+ // Read ONCE: `model` hands back a copy (ADR 0046), so reading it per item would make
407
+ // a splice of n items O(n²) instead of the O(n) the splice is.
408
+ const model = this._state.model;
409
+ for (let i = 0; i < change.added; i++) {
410
+ const option = model[change.position + i];
411
+ if (!option) continue;
412
+ const item = this._createItem(option);
413
+ this._listEl.insertBefore(item, before);
414
+ inserted.push(item);
415
+ }
416
+ this._itemButtons.splice(change.position, 0, ...inserted);
417
+
418
+ this._updateSelectedStates();
419
+ this._updateLabel();
420
+ if (this._state.count === 0) this._popoverEl.popdown();
421
+ }
379
422
  }
380
423
 
381
424
  customElements.define('gtk-drop-down', GtkDropDown);