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

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.
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.13-next.283",
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.13-next.283",
62
+ "@gjsify/adwaita-fonts": "0.64.13-next.283",
63
+ "@gjsify/adwaita-icons": "0.64.13-next.283",
64
+ "@gjsify/gtksource-core": "0.64.13-next.283",
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.13-next.283"
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.13-next.283",
77
+ "@gjsify/blueprint": "0.64.13-next.283",
78
+ "@gjsify/cli": "0.64.13-next.283",
79
+ "@gjsify/unit": "0.64.13-next.283",
80
+ "@gjsify/vite-plugin-blueprint": "0.64.13-next.283",
81
81
  "sass": "^1.101.0",
82
82
  "typescript": "^6.0.3"
83
83
  },
@@ -115,5 +115,5 @@
115
115
  "url": "https://github.com/gjsify/gjsify/issues"
116
116
  },
117
117
  "homepage": "https://github.com/gjsify/gjsify/tree/main/packages/web/adwaita-web#readme",
118
- "gitHead": "c4d2e266c0d9849860b9828c14ebfe37b6d493b8"
118
+ "gitHead": "05fae97125a374e84bf57c2d2d086fb53523f205"
119
119
  }
package/scss/_labels.scss CHANGED
@@ -109,6 +109,25 @@ gtk-label {
109
109
  max-width: 100%;
110
110
  }
111
111
 
112
+ // `gtk-label.ts`'s run-render path, SINGLE-LINE: the markup's runs move into this span
113
+ // because this element is a flex container (the two spacers above), and a node directly
114
+ // under one is a FLEX ITEM, not text — `vertical-align` does not apply to a flex item,
115
+ // so `<sup>`/`<sub>` stop being raised, and `align-items` centres each run on its own
116
+ // instead of the line sharing one baseline. Plain text never needed a box: a bare text
117
+ // node is ONE anonymous item, where a run list is one item per run.
118
+ //
119
+ // NO `max-width` HERE, unlike `.adw-label-text`: this box only ever holds ONE line (the
120
+ // host's `white-space: pre`, and this path is the not-wrapping, not-ellipsizing case),
121
+ // so there is no shrink-to-fit collapse to measure around — a single-line label as wide
122
+ // as its text, overflowing a narrower container, is what GtkLabel does and what
123
+ // `gtk-label.spec.ts` already pins for the plain-text path. The box's own size is its
124
+ // text's: `flex-basis: auto` over `width: auto` is the item's max-content width, which
125
+ // leaves the two spacers the free space `xalign` splits.
126
+ .adw-label-runs {
127
+ // Blockified by spec as a flex item; spelled out as `.adw-label-text` spells it out.
128
+ display: block;
129
+ }
130
+
112
131
  // `wrap-mode` — Pango's break-opportunity choice — is a real `word-break`/
113
132
  // `overflow-wrap` mapping, not a declared gap: CSS's `break-all` is Pango's CHAR (break
114
133
  // anywhere), and `overflow-wrap: anywhere` is Pango's WORD_CHAR (word boundaries, falling
package/scss/_link.scss CHANGED
@@ -1,12 +1,26 @@
1
1
  // _link.scss — `link` and `button.link`, libadwaita's link treatment.
2
2
  //
3
- // ONLY `button.link`, not a bare `.link`. Upstream's rule names the `link` NODE and
4
- // `button.link` (`_links.scss:1-2`); the `link` node is the inner node of a `Gtk.Label`'s
5
- // `<a>` markup, which no element here renders. The `.link` style CLASS on anything but a
6
- // button is unstyled upstream: `AdwLinkRow` adds it to its row (adw-link-row.c:377), and
7
- // no rule matches `row.link`, so its title stays in the row's own colour. A bare `.link`
8
- // here painted the About dialog's link rows blue and underlined. `<gtk-link-button>`
9
- // renders an inner `<button class="adw-button link">`, which the selector below reads.
3
+ // TWO NODES, NEVER A BARE `.link`. Upstream's rule names the `link` NODE and
4
+ // `button.link` (`_links.scss:1-2`). `button.link` is `<gtk-link-button>`'s inner
5
+ // `<button class="adw-button link">`, which the first selector below reads. The `link`
6
+ // node is the inner node of a `Gtk.Label`'s `<a>` markup, and `<gtk-label>` now DRAWS
7
+ // that — an `<a href>` per link in the markup (`label-markup.ts`) — so the second
8
+ // selector is upstream's `link` rule, ported onto the element that finally exists.
9
+ //
10
+ // The `.link` style CLASS on anything but those two is unstyled upstream: `AdwLinkRow`
11
+ // adds it to its row (adw-link-row.c:377), and no rule matches `row.link`, so its title
12
+ // stays in the row's own colour. A bare `.link` here painted the About dialog's link rows
13
+ // blue and underlined. It would now also hit `<gtk-label>` ITSELF, which gains the class
14
+ // whenever its markup has a link (gtklabel.c:3896) — the label is not a link, its anchors
15
+ // are. So both selectors name a node, and neither matches a class alone.
16
+ //
17
+ // THE LABEL'S ANCHORS ARE NOT STYLED THROUGH `:visited`, the CSS pseudo-class, even
18
+ // though they are real `<a href>` elements that would match it. A browser's `:visited` is
19
+ // the user's BROWSING HISTORY: a link followed in another tab, or last year, would come
20
+ // up visited in a label that has never been clicked, which `Gtk.Label` never does. Its
21
+ // `visited` is per-label state carried by URI across a re-parse
22
+ // (`labelLinksInheritVisited`), painted as `.visited` by `_paintVisited` — the same class
23
+ // `<gtk-link-button>` writes for the same reason one paragraph down.
10
24
  //
11
25
  // THE TWO COLOURS ARE THE PALETTE'S, not new tokens: `$link_color: var(--accent-color)`
12
26
  // and `$link_visited_color: color-mix(in srgb, var(--accent-color) 80%, var(--view-fg-color))`
@@ -36,6 +50,7 @@
36
50
  $link-color: var(--accent-color);
37
51
  $link-visited-color: color-mix(in srgb, var(--accent-color) 80%, var(--view-fg-color));
38
52
  $osd-link-color: color-mix(in srgb, var(--accent-bg-color) 50%, white);
53
+ $osd-link-visited-color: color-mix(in srgb, var(--accent-bg-color) 75%, white);
39
54
 
40
55
  .adw-button.link {
41
56
  color: $link-color;
@@ -82,4 +97,55 @@ $osd-link-color: color-mix(in srgb, var(--accent-bg-color) 50%, white);
82
97
  &.checked {
83
98
  background-color: transparent;
84
99
  }
100
+ }
101
+
102
+ // Upstream's bare `link` node — a `Gtk.Label`'s `<a href>` markup, which `<gtk-label>`
103
+ // draws as a real anchor per link (`label-markup.ts`, `buildAnchor`).
104
+ //
105
+ // THE SELECTOR IS THE ATTRIBUTE, not the class. `data-gtk-link` is written by the renderer
106
+ // and by nothing else, so it names the node the way upstream's `link` names a GTK node;
107
+ // the `.link` class on the same anchor is GTK's own style class, which a consumer may also
108
+ // put on anything (and `<gtk-label>` ITSELF carries it whenever its markup has a link —
109
+ // see the header). Scoped under `gtk-label` so an `<a>` a consumer put in a label's slot
110
+ // for its own reasons is untouched.
111
+ gtk-label a[data-gtk-link] {
112
+ color: $link-color;
113
+ text-decoration: underline;
114
+ font-weight: inherit;
115
+
116
+ &:hover {
117
+ color: hsl(from $link-color h calc(s * 1.1) calc(l * 1.1));
118
+ }
119
+
120
+ &:active {
121
+ color: $link-color;
122
+ }
123
+
124
+ // `.visited` and NOT `:visited` — the pseudo-class is the user's browsing history, where
125
+ // `Gtk.Label`'s `visited` is per-label state carried by URI (`_paintVisited`, and the
126
+ // paragraph in the header).
127
+ &.visited {
128
+ color: $link-visited-color;
129
+ }
130
+
131
+ .osd & {
132
+ color: $osd-link-color;
133
+
134
+ &.visited {
135
+ color: $osd-link-visited-color;
136
+ }
137
+
138
+ &:active {
139
+ color: $osd-link-color;
140
+ }
141
+ }
142
+
143
+ // `link { @include focus-ring() }` (_links.scss:33-35) — the mixin is a 2px outline at
144
+ // offset -2px (`_drawing.scss`), spelled out as `_expander.scss` and `_checks.scss`
145
+ // spell it out. `:focus-visible` rather than `:focus`: GTK draws the ring on keyboard
146
+ // focus, and a click on a link must not leave one behind.
147
+ &:focus-visible {
148
+ outline: 2px solid var(--accent-color);
149
+ outline-offset: -2px;
150
+ }
85
151
  }
@@ -19,6 +19,7 @@ import { describe, expect, it } from '@gjsify/unit';
19
19
 
20
20
  import {
21
21
  ACTION_ROW_ACTIVATION_VECTORS,
22
+ ACTION_ROW_SUBTITLE_VECTORS,
22
23
  BUTTON_ROW_ACTIVATABLE_VECTORS,
23
24
  BUTTON_ROW_ICON_VECTORS,
24
25
  LABEL_VISIBILITY_VECTORS,
@@ -98,6 +99,24 @@ export const AdwActionRowsTest = async () => {
98
99
  });
99
100
  });
100
101
 
102
+ await describe('<adw-action-row> subtitle (libadwaita conformance vectors)', async () => {
103
+ // The element is attribute-driven and publishes no `notify::subtitle`, so only the
104
+ // value the vectors hold afterwards is observable; `null` is an absent attribute.
105
+ for (const vector of ACTION_ROW_SUBTITLE_VECTORS) {
106
+ await it(`${vector.name} — ${vector.rule}`, () => {
107
+ const { el: row, host } = mount<AdwActionRow>('adw-action-row');
108
+ for (const step of vector.steps) {
109
+ if (step.write === null) row.removeAttribute('subtitle');
110
+ else row.setAttribute('subtitle', step.write);
111
+ const label = row.querySelector('.adw-row-subtitle') as HTMLElement;
112
+ expect(label.textContent).toBe(step.subtitle);
113
+ expect(label.hidden).toBe(step.subtitle === '');
114
+ }
115
+ host.remove();
116
+ });
117
+ }
118
+ });
119
+
101
120
  await describe('<adw-action-row> activatable-widget (libadwaita conformance vectors)', async () => {
102
121
  for (const vector of ACTION_ROW_ACTIVATION_VECTORS) {
103
122
  await it(`${vector.name} — ${vector.rule}`, async () => {
@@ -13,15 +13,21 @@ 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,
18
+ EXPANDER_ROW_CONSTRUCT_VECTORS,
19
+ EXPANDER_ROW_INITIAL,
20
+ EXPANDER_ROW_VECTORS,
17
21
  LIST_ITEMS_CHANGED_VECTORS,
18
22
  LIST_MODEL_OWNERSHIP_VECTORS,
19
23
  LIST_NORMALIZE_VECTORS,
20
24
  LIST_PARSE_VECTORS,
21
- LIST_SELECTION_CLAMP_VECTORS,
25
+ applyListMutation,
22
26
  applyListReadback,
27
+ listModelForInput,
23
28
  } from '@gjsify/adwaita-core/conformance';
24
29
  import type { ComboSelectionStep } from '@gjsify/adwaita-core/conformance';
30
+ import { GjsifyOptionList } from '@gjsify/adwaita-core';
25
31
 
26
32
  import type { AdwComboRow } from './elements/adw-combo-row.js';
27
33
  import type { AdwExpanderRow } from './elements/adw-expander-row.js';
@@ -51,17 +57,24 @@ function record(el: HTMLElement, event: string): { details: unknown[]; stop: ()
51
57
  *
52
58
  * The row can drive the table at all only because the model and the select-by-value are
53
59
  * 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.
60
+ * these rows. It drives EVERY row with no carve-out — `an index past the end` used to be
61
+ * the one the two browser selectors answered differently and ADR 0122 measured it instead,
62
+ * so the permissive half this element carried is gone.
63
+ *
64
+ * Every `setModel` builds a FRESH list, which is what makes `setModelAgain` — assigning the
65
+ * identity the row already holds — a distinct step rather than a repeat of the one above it.
58
66
  */
59
67
  function applyComboStep(row: AdwComboRow, step: ComboSelectionStep): void {
60
68
  const select = row.querySelector('select') as HTMLSelectElement;
61
69
  switch (step.op) {
62
70
  case 'setModel':
63
- row.model = step.model;
71
+ row.model = GjsifyOptionList.new([...step.model]);
72
+ return;
73
+ case 'setModelAgain': {
74
+ const held = row.model;
75
+ row.model = held;
64
76
  return;
77
+ }
65
78
  case 'setSelectedIndex':
66
79
  row.selected = step.index;
67
80
  return;
@@ -155,7 +168,7 @@ export const AdwRowStateTest = async () => {
155
168
 
156
169
  // Through the PUBLISHED path a consumer has — the same `model` property
157
170
  // `<gtk-drop-down>` publishes, not a reach into the composed state.
158
- row.model = ['One', 'Two'];
171
+ row.model = GjsifyOptionList.new(['One', 'Two']);
159
172
  expect(select.disabled).toBe(false);
160
173
  expect(row.dataset.presentsChooser).toBe('true');
161
174
 
@@ -177,10 +190,10 @@ export const AdwRowStateTest = async () => {
177
190
  const value = row.querySelector('.adw-row-value') as HTMLSpanElement;
178
191
  expect(select.options.length).toBe(2);
179
192
 
180
- // The PROPERTY, with the plain-string vocabulary both selectors accept.
181
- row.model = ['Cyan', 'Magenta', 'Yellow'];
193
+ // The PROPERTY, with the plain-string vocabulary `GjsifyOptionList` accepts.
194
+ row.model = GjsifyOptionList.new(['Cyan', 'Magenta', 'Yellow']);
182
195
  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');
196
+ expect(row.options.map((o) => o.label).join('|')).toBe('Cyan|Magenta|Yellow');
184
197
  // The model was replaced under an index the new one still has, so autoselect
185
198
  // keeps 0 and the inline label has to follow the NEW option at that index.
186
199
  expect(value.textContent).toBe('Cyan');
@@ -205,12 +218,12 @@ export const AdwRowStateTest = async () => {
205
218
  );
206
219
  const select = row.querySelector('select') as HTMLSelectElement;
207
220
 
208
- row.model = [
221
+ row.model = GjsifyOptionList.new([
209
222
  { value: 'r', label: 'Red' },
210
223
  { value: 'g', label: 'Green' },
211
- ];
224
+ ]);
212
225
  expect([...select.options].map((o) => o.textContent).join('|')).toBe('Red|Green');
213
- expect(row.model.map((o) => o.value).join('|')).toBe('r|g');
226
+ expect(row.options.map((o) => o.value).join('|')).toBe('r|g');
214
227
 
215
228
  // And selection by VALUE, the drop-down's other published setter.
216
229
  row.selectedValue = 'g';
@@ -223,42 +236,50 @@ export const AdwRowStateTest = async () => {
223
236
  // The row publishes the drop-down's property set apart from `enableSearch` and
224
237
  // `active`, which are that element's popover chrome. `selectedItem` is NOT chrome —
225
238
  // 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 () => {
239
+ // property is, and `null` rather than a blank item when nothing is addressed.
240
+ //
241
+ // THE MODEL'S OWN ITEM, by identity (ADR 0122 § 4): `row.selectedItem ===
242
+ // model.get_item(row.selected)`, which is the comparison a `selected-item` consumer
243
+ // writes and the one a normalised descriptor would have failed.
244
+ await it('reads the selected item back by identity, and null when nothing is addressed', async () => {
228
245
  const { el: row, host } = parse<AdwComboRow>(
229
246
  `<adw-combo-row title="Pick"></adw-combo-row>`,
230
247
  'adw-combo-row',
231
248
  );
232
249
 
250
+ // No model at all — `null` and not an item of an empty list.
251
+ expect(row.model).toBeNull();
233
252
  expect(row.selectedItem).toBeNull();
234
253
 
235
- row.model = [
254
+ const model = GjsifyOptionList.new([
236
255
  { value: 'r', label: 'Red' },
237
256
  { value: 'g', label: 'Green' },
238
- ];
239
- expect(row.selectedItem?.value).toBe('r');
257
+ ]);
258
+ row.model = model;
259
+ expect(row.model === model).toBe(true);
260
+ expect(row.selectedItem === model.get_item(0)).toBe(true);
240
261
  row.selected = 1;
241
- expect(row.selectedItem?.label).toBe('Green');
262
+ expect(row.selectedItem === model.get_item(1)).toBe(true);
242
263
 
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.
264
+ // An index past the end is IGNORED, as measured in libadwaita — the selection
265
+ // stays where it was, and nothing reads empty. This element used to ACCEPT the
266
+ // set and read back blank, the one row of the shared table the two browser
267
+ // selectors answered differently; the permissive half was ours, not GTK's.
247
268
  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('');
269
+ expect(row.selected).toBe(1);
270
+ expect(row.selectedValue).toBe('g');
271
+ expect((row.querySelector('.adw-row-value') as HTMLSpanElement).textContent).toBe('Green');
252
272
 
253
273
  host.remove();
254
274
  });
255
275
  });
256
276
 
257
- // The portable list model (ADR 0046), driven through this row's own `model` door.
277
+ // The portable list model (ADR 0046), driven through this row's own `model` doors — the
278
+ // property for a real model, the attribute for markup.
258
279
  //
259
280
  // 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
281
+ // compare `<option>` node IDENTITY across a markup update, which is the difference
282
+ // between splicing the list and rebuilding it — a difference no assertion about the
262
283
  // model, the labels or the selection can see. Before the splice landed, every row with
263
284
  // a non-zero `survivors` scored 0.
264
285
  await describe('adw-combo-row list model (portable list-model vectors)', async () => {
@@ -268,8 +289,10 @@ export const AdwRowStateTest = async () => {
268
289
  `<adw-combo-row title="Pick"></adw-combo-row>`,
269
290
  'adw-combo-row',
270
291
  );
271
- row.model = vector.input;
272
- expect(row.model).toStrictEqual([...vector.model]);
292
+ // `listModelForInput` is the table's own answer to which door a row reaches
293
+ // through now that `model` takes no array (ADR 0122 § 4).
294
+ row.model = listModelForInput(vector.input);
295
+ expect(row.options).toStrictEqual([...vector.model]);
273
296
  const select = row.querySelector('select') as HTMLSelectElement;
274
297
  expect([...select.options].map((option) => option.textContent)).toStrictEqual(
275
298
  vector.model.map((item) => item.label),
@@ -287,7 +310,7 @@ export const AdwRowStateTest = async () => {
287
310
  // Set AFTER connect, so the attribute reaches the callback rather than the
288
311
  // seeding path: a total parser has to be total on the live door too.
289
312
  row.setAttribute('model', vector.attribute);
290
- expect(row.model).toStrictEqual([...vector.model]);
313
+ expect(row.options).toStrictEqual([...vector.model]);
291
314
  host.remove();
292
315
  });
293
316
  }
@@ -299,10 +322,16 @@ export const AdwRowStateTest = async () => {
299
322
  'adw-combo-row',
300
323
  );
301
324
  const select = row.querySelector('select') as HTMLSelectElement;
302
- row.model = vector.previous;
325
+ // Driven through the MARKUP door, which is the one that still computes a
326
+ // splice: the attribute reconciles the element's own authored list (ADR 0122
327
+ // amendment A4), keeping its identity, so the survivors these rows count are
328
+ // the `<option>` nodes a minimal splice left alone. A `model` PROPERTY write
329
+ // cannot answer them any more — a different list is a replacement, and a
330
+ // replacement carries nothing over by definition.
331
+ row.setAttribute('model', JSON.stringify(vector.previous));
303
332
  const before = [...select.options];
304
333
 
305
- row.model = vector.next;
334
+ row.setAttribute('model', JSON.stringify(vector.next));
306
335
 
307
336
  const after = [...select.options];
308
337
  expect(after.map((option) => option.textContent)).toStrictEqual(vector.next.map((item) => item.label));
@@ -311,10 +340,10 @@ export const AdwRowStateTest = async () => {
311
340
  });
312
341
  }
313
342
 
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.
343
+ // WHO OWNS the options on either side of the model, and what the model's own API
344
+ // moves. The DOM is what makes these rows worth driving at the element: on the
345
+ // caller-side rows the descriptors still read correctly from the model itself, and
346
+ // only the `<option>` list shows whether a splice was emitted for them.
318
347
  for (const vector of LIST_MODEL_OWNERSHIP_VECTORS) {
319
348
  await it(`ownership — ${vector.rule}`, async () => {
320
349
  const { el: row, host } = parse<AdwComboRow>(
@@ -323,39 +352,23 @@ export const AdwRowStateTest = async () => {
323
352
  );
324
353
  const select = row.querySelector('select') as HTMLSelectElement;
325
354
  const input = vector.initial.map((item) => ({ ...item }));
326
- row.model = input;
355
+ const list = GjsifyOptionList.new(input);
356
+ row.model = list;
327
357
  const before = [...select.options];
328
358
 
329
- const mutated = applyListReadback(vector.source === 'input' ? input : row.model, vector.op);
330
- if (vector.assign) row.model = mutated;
359
+ if (vector.source === 'model') applyListMutation(list, vector.op);
360
+ else applyListReadback(vector.source === 'input' ? input : row.options, vector.op);
331
361
 
332
- expect(row.model).toStrictEqual([...vector.model]);
362
+ expect(row.options).toStrictEqual([...vector.model]);
363
+ // None of these ops is a replacement, so the element must still be pointing
364
+ // at the list it was handed.
365
+ expect(row.model).toBe(list);
333
366
  const after = [...select.options];
334
367
  expect(after.map((option) => option.textContent)).toStrictEqual(vector.model.map((item) => item.label));
335
368
  expect(before.filter((option) => after.includes(option)).length).toBe(vector.survivors);
336
369
  host.remove();
337
370
  });
338
371
  }
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
372
  });
360
373
 
361
374
  // The SAME table `<gtk-drop-down>` and the NativeScript renderer are held to
@@ -394,6 +407,53 @@ export const AdwRowStateTest = async () => {
394
407
  }
395
408
  });
396
409
 
410
+ // The model MOVING under a live row — the axis that only exists since `model` holds a
411
+ // real `Gio.ListModel` (ADR 0122 § 6). Nothing re-assigns `model` here: the list is
412
+ // spliced through its own API and the row has to follow, which is what separates this
413
+ // from every replacement row above it.
414
+ //
415
+ // What a DOM surface can show is the OUTCOME — `selected`, `selectedValue`, the inline
416
+ // label and the rebuilt `<option>` list — plus the silence: a model moving underneath is
417
+ // not a user pick, so `notify::selected` must not fire. The vectors' `notified` column is
418
+ // the GObject half this element has no spelling for and the core suite asserts alone.
419
+ await describe('adw-combo-row items-changed on the held model (GtkSingleSelection)', async () => {
420
+ for (const vector of COMBO_ITEMS_CHANGED_VECTORS) {
421
+ await it(`${vector.name} — ${vector.rule}`, async () => {
422
+ const { el: row, host } = parse<AdwComboRow>(
423
+ `<adw-combo-row title="Pick"></adw-combo-row>`,
424
+ 'adw-combo-row',
425
+ );
426
+ const select = row.querySelector('select') as HTMLSelectElement;
427
+ const value = row.querySelector('.adw-row-value') as HTMLSpanElement;
428
+
429
+ const list = GjsifyOptionList.new([...vector.initial]);
430
+ row.model = list;
431
+ if (vector.selected >= 0) row.selected = vector.selected;
432
+ expect(row.selected, 'the row set up the selection it says it did').toBe(vector.selected);
433
+
434
+ const events = record(row, 'notify::selected');
435
+ list.splice(vector.splice.position, vector.splice.removed, [...vector.splice.added]);
436
+
437
+ const expected = [...vector.initial];
438
+ expected.splice(vector.splice.position, vector.splice.removed, ...vector.splice.added);
439
+
440
+ expect(row.selected).toBe(vector.result);
441
+ expect(row.selectedValue).toBe(vector.value);
442
+ expect(value.textContent).toBe(expected[vector.result]?.label ?? '');
443
+ // The drawn list follows the MODEL: the row was never re-assigned, so a
444
+ // `<select>` still showing the old options is exactly the bug the
445
+ // `items-changed` channel exists to prevent.
446
+ expect([...select.options].map((option) => option.textContent)).toStrictEqual(
447
+ expected.map((option) => option.label),
448
+ );
449
+ expect(events.details).toStrictEqual([]);
450
+
451
+ events.stop();
452
+ host.remove();
453
+ });
454
+ }
455
+ });
456
+
397
457
  await describe('adw-spin-row (SpinState)', async () => {
398
458
  // THE SHARED TABLES, driven through the ELEMENT and its markup door — the same rows
399
459
  // the core suite and the NativeScript suite assert, so a range authored in one
@@ -722,6 +782,83 @@ export const AdwRowStateTest = async () => {
722
782
  host.remove();
723
783
  });
724
784
 
785
+ for (const vector of EXPANDER_ROW_VECTORS) {
786
+ await it(`holds ${vector.name} (shared conformance vectors)`, async () => {
787
+ const { el: row, host } = parse<AdwExpanderRow>(
788
+ '<adw-expander-row title="More"><adw-action-row title="Nested"></adw-action-row></adw-expander-row>',
789
+ 'adw-expander-row',
790
+ );
791
+ expect(row.expanded).toBe(EXPANDER_ROW_INITIAL.expanded);
792
+ expect(row.enableExpansion).toBe(EXPANDER_ROW_INITIAL.enableExpansion);
793
+ for (const step of vector.steps) {
794
+ if (step.op === 'expanded') row.expanded = step.value;
795
+ else row.enableExpansion = step.value;
796
+ // A DOM element publishes only the user's notifications, so `notified` is
797
+ // not observable here — the held values and what they reflect to are.
798
+ expect(row.expanded).toBe(step.expanded);
799
+ expect(row.enableExpansion).toBe(step.enableExpansion);
800
+ expect(row.hasAttribute('expanded')).toBe(step.expanded);
801
+ expect(row.classList.contains('expanded')).toBe(step.expanded);
802
+ }
803
+ host.remove();
804
+ });
805
+ }
806
+
807
+ for (const vector of EXPANDER_ROW_CONSTRUCT_VECTORS) {
808
+ await it(`parses expanded=${vector.expanded}, enable-expansion=${vector.enableExpansion} — ${vector.rule}`, async () => {
809
+ const enable = `enable-expansion="${vector.enableExpansion}"`;
810
+ const expanded = vector.expanded ? 'expanded' : '';
811
+ // Both attribute orders: the parser delivers them in source order.
812
+ for (const attributes of [`${enable} ${expanded}`, `${expanded} ${enable}`]) {
813
+ const { el: row, host } = parse<AdwExpanderRow>(
814
+ `<adw-expander-row title="x" ${attributes}></adw-expander-row>`,
815
+ 'adw-expander-row',
816
+ );
817
+ expect(row.enableExpansion).toBe(vector.enableExpansion);
818
+ expect(row.expanded).toBe(vector.resultExpanded);
819
+ expect(row.hasAttribute('expanded')).toBe(vector.resultExpanded);
820
+ host.remove();
821
+ }
822
+ });
823
+ }
824
+
825
+ await it('does not open on a header click while expansion is disabled', async () => {
826
+ const { el: row, host } = parse<AdwExpanderRow>(
827
+ '<adw-expander-row title="More" enable-expansion="false"></adw-expander-row>',
828
+ 'adw-expander-row',
829
+ );
830
+ const header = row.querySelector('.adw-expander-row-header') as HTMLDivElement;
831
+ const events = record(row, 'notify::expanded');
832
+ header.click();
833
+ expect(row.expanded).toBe(false);
834
+ expect(events.details.length).toBe(0);
835
+ events.stop();
836
+ host.remove();
837
+ });
838
+
839
+ await it('the enable switch publishes expanded BEFORE enable-expansion, as libadwaita emits them', async () => {
840
+ const { el: row, host } = parse<AdwExpanderRow>(
841
+ '<adw-expander-row title="More" show-enable-switch enable-expansion="false"></adw-expander-row>',
842
+ 'adw-expander-row',
843
+ );
844
+ const seen: string[] = [];
845
+ row.addEventListener('notify::expanded', () => seen.push('expanded'));
846
+ row.addEventListener('notify::enable-expansion', () => seen.push('enable-expansion'));
847
+ const toggle = row.querySelector('.adw-expander-row-enable-switch') as HTMLElement & { active: boolean };
848
+
849
+ toggle.active = true;
850
+ expect(seen.join()).toBe('expanded,enable-expansion');
851
+ expect(row.expanded).toBe(true);
852
+ expect(row.hasAttribute('expanded')).toBe(true);
853
+
854
+ seen.length = 0;
855
+ toggle.active = false;
856
+ expect(seen.join()).toBe('expanded,enable-expansion');
857
+ expect(row.expanded).toBe(false);
858
+ expect(row.hasAttribute('expanded')).toBe(false);
859
+ host.remove();
860
+ });
861
+
725
862
  await it('adopts a declarative expanded attribute', async () => {
726
863
  const { el: row, host } = parse<AdwExpanderRow>(
727
864
  '<adw-expander-row title="Open" expanded></adw-expander-row>',
@@ -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;