@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.
@@ -8,7 +8,9 @@
8
8
  // composes the same `ComboState` and drives the same table from its own DOM
9
9
  // (`adw-row-state.spec.ts`) — it could not while two of its four step ops had no DOM
10
10
  // spelling there, its model arriving through an attribute at connect time only (#1525).
11
- // The two are worth driving separately because they part on ONE row, below.
11
+ // The two used to part on ONE row — `an index past the end`, which this element rejected
12
+ // and the row accepted — and that carve-out is GONE: ADR 0122 § 4 measured libadwaita,
13
+ // found the rejection right, and the row adopted it. Both selectors now drive every row.
12
14
  import { describe, it, expect } from '@gjsify/unit';
13
15
 
14
16
  import {
@@ -17,9 +19,13 @@ import {
17
19
  LIST_MODEL_OWNERSHIP_VECTORS,
18
20
  LIST_NORMALIZE_VECTORS,
19
21
  LIST_PARSE_VECTORS,
22
+ applyListMutation,
20
23
  applyListReadback,
24
+ listModelForInput,
21
25
  } from '@gjsify/adwaita-core/conformance';
22
- import type { ComboSelectionStep, ComboSelectionVector } from '@gjsify/adwaita-core/conformance';
26
+ import type { ComboSelectionStep } from '@gjsify/adwaita-core/conformance';
27
+ import { GjsifyOptionList } from '@gjsify/adwaita-core';
28
+ import type { AdwComboOptionInput } from '@gjsify/adwaita-core';
23
29
 
24
30
  import type { GtkDropDown } from './elements/gtk-drop-down.js';
25
31
 
@@ -29,6 +35,20 @@ function makeDropDown(): GtkDropDown {
29
35
  return el;
30
36
  }
31
37
 
38
+ /**
39
+ * Assign a model built out of an authored array.
40
+ *
41
+ * The array itself no longer goes to the property — ADR 0122 § 4 narrowed `model` to a real
42
+ * `Gio.ListModel` — so every test below that is about something OTHER than the door says so
43
+ * in one place. The tests that ARE about the door (`LIST_NORMALIZE_VECTORS` and the array
44
+ * refusal in `gtk-value-doors.spec.ts`) write to `model` directly.
45
+ */
46
+ function setModel(dd: GtkDropDown, options: readonly AdwComboOptionInput[]): GjsifyOptionList {
47
+ const list = GjsifyOptionList.new(options);
48
+ dd.model = list;
49
+ return list;
50
+ }
51
+
32
52
  /** The text the closed button shows — the rendered `selectedLabel`. */
33
53
  function labelText(dd: GtkDropDown): string {
34
54
  return dd.querySelector('.adw-drop-down-label')?.textContent ?? '';
@@ -42,12 +62,21 @@ function openChooser(dd: GtkDropDown): void {
42
62
  /**
43
63
  * Replay one vector step against the real element — property sets for the
44
64
  * programmatic ops, a popover gesture for the interactive one.
65
+ *
66
+ * Every `setModel` builds a FRESH list, which is what makes `setModelAgain` — assigning the
67
+ * identity the element already holds — a distinct step rather than a repeat of the one above
68
+ * it.
45
69
  */
46
70
  function applyStep(dd: GtkDropDown, step: ComboSelectionStep): void {
47
71
  switch (step.op) {
48
72
  case 'setModel':
49
- dd.model = step.model;
73
+ setModel(dd, [...step.model]);
74
+ return;
75
+ case 'setModelAgain': {
76
+ const held = dd.model;
77
+ dd.model = held;
50
78
  return;
79
+ }
51
80
  case 'setSelectedIndex':
52
81
  dd.selected = step.index;
53
82
  return;
@@ -67,23 +96,6 @@ function applyStep(dd: GtkDropDown, step: ComboSelectionStep): void {
67
96
  }
68
97
  }
69
98
 
70
- /**
71
- * The ONE row this element answers differently, on purpose: its published `selected`
72
- * contract REJECTS an out-of-range set (`_selectIndex` gates on `ComboState.hasIndex`)
73
- * where the core state accepts it and reports an empty value — the split
74
- * `ComboState.hasIndex`'s doc records and `<adw-combo-row>` takes the other side of. The
75
- * row is still driven below, against the element's answer, so neither policy can change
76
- * silently.
77
- */
78
- const DROP_DOWN_REJECTS = 'an index past the end';
79
-
80
- /** The named vector — a hard failure if the table renamed it out from under the carve-out. */
81
- function vectorNamed(name: string): ComboSelectionVector {
82
- const vector = COMBO_SELECTION_VECTORS.find((v) => v.name === name);
83
- if (!vector) throw new Error(`COMBO_SELECTION_VECTORS has no row "${name}" — this spec's carve-out is stale`);
84
- return vector;
85
- }
86
-
87
99
  /** Mount a drop-down and record every `change` detail it dispatches. */
88
100
  function mountRecording(): { dd: GtkDropDown; changes: unknown[] } {
89
101
  const dd = makeDropDown();
@@ -95,8 +107,6 @@ function mountRecording(): { dd: GtkDropDown; changes: unknown[] } {
95
107
  export const GtkDropDownTest = async () => {
96
108
  await describe('gtk-drop-down selection (libadwaita conformance vectors)', async () => {
97
109
  for (const vector of COMBO_SELECTION_VECTORS) {
98
- if (vector.name === DROP_DOWN_REJECTS) continue;
99
-
100
110
  await it(`${vector.name} — ${vector.rule}`, async () => {
101
111
  const { dd, changes } = mountRecording();
102
112
  for (const step of vector.steps) applyStep(dd, step);
@@ -115,43 +125,24 @@ export const GtkDropDownTest = async () => {
115
125
  dd.remove();
116
126
  });
117
127
  }
118
-
119
- await it(`${DROP_DOWN_REJECTS} — rejected here: the element's published contract, not the state's`, async () => {
120
- const vector = vectorNamed(DROP_DOWN_REJECTS);
121
- const { dd, changes } = mountRecording();
122
-
123
- for (const step of vector.steps.slice(0, -1)) applyStep(dd, step);
124
- const before = { selected: dd.selected, value: dd.selectedValue, label: labelText(dd) };
125
- // The out-of-range set itself, which the core state would accept and report
126
- // as nothing-to-draw (`value`/`label` both empty below).
127
- expect(vector.value).toBe('');
128
- expect(vector.label).toBe('');
129
- applyStep(dd, vector.steps[vector.steps.length - 1]);
130
-
131
- // The element refused it: the selection did not move, and a refusal
132
- // is not a change, so nothing was dispatched either.
133
- expect({ selected: dd.selected, value: dd.selectedValue, label: labelText(dd) }).toStrictEqual(before);
134
- expect(changes).toStrictEqual([]);
135
- dd.remove();
136
- });
137
128
  });
138
129
 
139
130
  await describe('gtk-drop-down model', async () => {
140
131
  await it('accepts a string[] (value === label)', async () => {
141
132
  const dd = makeDropDown();
142
- dd.model = ['One', 'Two', 'Three'];
143
- expect(dd.model.length).toBe(3);
144
- expect(dd.model[0]).toStrictEqual({ value: 'One', label: 'One' });
133
+ setModel(dd, ['One', 'Two', 'Three']);
134
+ expect(dd.options.length).toBe(3);
135
+ expect(dd.options[0]).toStrictEqual({ value: 'One', label: 'One' });
145
136
  expect(dd.querySelector('.adw-drop-down-label')?.textContent).toBe('One');
146
137
  dd.remove();
147
138
  });
148
139
 
149
140
  await it('accepts {value,label}[] and shows the selected label', async () => {
150
141
  const dd = makeDropDown();
151
- dd.model = [
142
+ setModel(dd, [
152
143
  { value: 'a', label: 'Apple' },
153
144
  { value: 'b', label: 'Banana' },
154
- ];
145
+ ]);
155
146
  dd.selected = 1;
156
147
  expect(dd.querySelector('.adw-drop-down-label')?.textContent).toBe('Banana');
157
148
  expect(dd.selectedValue).toBe('b');
@@ -163,7 +154,7 @@ export const GtkDropDownTest = async () => {
163
154
  dd.setAttribute('model', '[{"value":"x","label":"Ex"},{"value":"y","label":"Why"}]');
164
155
  dd.setAttribute('selected', '1');
165
156
  document.body.appendChild(dd);
166
- expect(dd.model.length).toBe(2);
157
+ expect(dd.options.length).toBe(2);
167
158
  expect(dd.selected).toBe(1);
168
159
  expect(dd.selectedValue).toBe('y');
169
160
  dd.remove();
@@ -171,7 +162,7 @@ export const GtkDropDownTest = async () => {
171
162
 
172
163
  await it('renders one popover item per option', async () => {
173
164
  const dd = makeDropDown();
174
- dd.model = ['a', 'b', 'c'];
165
+ setModel(dd, ['a', 'b', 'c']);
175
166
  expect(dd.querySelectorAll('.adw-drop-down-item').length).toBe(3);
176
167
  dd.remove();
177
168
  });
@@ -179,8 +170,10 @@ export const GtkDropDownTest = async () => {
179
170
  for (const vector of LIST_NORMALIZE_VECTORS) {
180
171
  await it(`normalize — ${vector.rule}`, async () => {
181
172
  const dd = makeDropDown();
182
- dd.model = vector.input;
183
- expect(dd.model).toStrictEqual([...vector.model]);
173
+ // `listModelForInput` is the table's own answer to which door a row reaches
174
+ // through now that `model` takes no array (ADR 0122 § 4).
175
+ dd.model = listModelForInput(vector.input);
176
+ expect(dd.options).toStrictEqual([...vector.model]);
184
177
  expect(
185
178
  [...dd.querySelectorAll('.adw-drop-down-item-label')].map((label) => label.textContent),
186
179
  ).toStrictEqual(vector.model.map((item) => item.label));
@@ -198,21 +191,26 @@ export const GtkDropDownTest = async () => {
198
191
  // Set AFTER connect, so the attribute reaches the callback rather than the
199
192
  // seeding path: a total parser has to be total on the live door too.
200
193
  dd.setAttribute('model', vector.attribute);
201
- expect(dd.model).toStrictEqual([...vector.model]);
194
+ expect(dd.options).toStrictEqual([...vector.model]);
202
195
  dd.remove();
203
196
  });
204
197
  }
205
198
 
206
- // The half no pure test reaches: a replaced model must SPLICE the row buttons, not
199
+ // The half no pure test reaches: a markup update must SPLICE the row buttons, not
207
200
  // rebuild them. Identity is what says which happened — and it is what a user feels,
208
201
  // because a rebuilt row loses its focus and the popover its scroll position.
202
+ //
203
+ // Driven through the ATTRIBUTE, which is the door that still computes a splice: it
204
+ // reconciles the element's own authored list (ADR 0122 amendment A4) and so keeps its
205
+ // identity. A `model` PROPERTY write cannot answer these rows any more — a different
206
+ // list is a replacement, and a replacement carries nothing over by definition.
209
207
  for (const vector of LIST_ITEMS_CHANGED_VECTORS) {
210
208
  await it(`items-changed — ${vector.rule}`, async () => {
211
209
  const dd = makeDropDown();
212
- dd.model = vector.previous;
210
+ dd.setAttribute('model', JSON.stringify(vector.previous));
213
211
  const before = [...dd.querySelectorAll('.adw-drop-down-item')];
214
212
 
215
- dd.model = vector.next;
213
+ dd.setAttribute('model', JSON.stringify(vector.next));
216
214
 
217
215
  const after = [...dd.querySelectorAll('.adw-drop-down-item')];
218
216
  expect(after.map((item) => item.querySelector('.adw-drop-down-item-label')?.textContent)).toStrictEqual(
@@ -223,19 +221,23 @@ export const GtkDropDownTest = async () => {
223
221
  });
224
222
  }
225
223
 
226
- // WHO OWNS the array `model` hands back — the row buttons are what shows it, for
227
- // the reason `adw-row-state.spec.ts` gives on the same table.
224
+ // WHO OWNS the options either side of the model, and what the model's own API moves
225
+ // — the row buttons are what shows it, for the reason `adw-row-state.spec.ts` gives
226
+ // on the same table.
228
227
  for (const vector of LIST_MODEL_OWNERSHIP_VECTORS) {
229
228
  await it(`ownership — ${vector.rule}`, async () => {
230
229
  const dd = makeDropDown();
231
230
  const input = vector.initial.map((item) => ({ ...item }));
232
- dd.model = input;
231
+ const list = setModel(dd, input);
233
232
  const before = [...dd.querySelectorAll('.adw-drop-down-item')];
234
233
 
235
- const mutated = applyListReadback(vector.source === 'input' ? input : dd.model, vector.op);
236
- if (vector.assign) dd.model = mutated;
234
+ if (vector.source === 'model') applyListMutation(list, vector.op);
235
+ else applyListReadback(vector.source === 'input' ? input : dd.options, vector.op);
237
236
 
238
- expect(dd.model).toStrictEqual([...vector.model]);
237
+ expect(dd.options).toStrictEqual([...vector.model]);
238
+ // None of these ops is a replacement, so the element must still be pointing
239
+ // at the list it was handed.
240
+ expect(dd.model).toBe(list);
239
241
  const after = [...dd.querySelectorAll('.adw-drop-down-item')];
240
242
  expect(after.map((item) => item.querySelector('.adw-drop-down-item-label')?.textContent)).toStrictEqual(
241
243
  vector.model.map((item) => item.label),
@@ -250,9 +252,10 @@ export const GtkDropDownTest = async () => {
250
252
  // not, so the handler asks the list where the row is now.
251
253
  await it('a row that a splice shifted still selects itself', async () => {
252
254
  const dd = makeDropDown();
253
- dd.model = ['b', 'c'];
255
+ // Through the attribute door, the one that splices — see the items-changed loop.
256
+ dd.setAttribute('model', '["b","c"]');
254
257
  const rowForB = dd.querySelectorAll('.adw-drop-down-item')[0] as HTMLButtonElement;
255
- dd.model = ['a', 'b', 'c'];
258
+ dd.setAttribute('model', '["a","b","c"]');
256
259
  expect(dd.querySelectorAll('.adw-drop-down-item')[1]).toBe(rowForB);
257
260
 
258
261
  (dd.querySelector('.adw-drop-down-button') as HTMLButtonElement).click();
@@ -265,11 +268,11 @@ export const GtkDropDownTest = async () => {
265
268
  await describe('gtk-drop-down selected ↔ value sync', async () => {
266
269
  await it('selectedValue setter finds the index by value', async () => {
267
270
  const dd = makeDropDown();
268
- dd.model = [
271
+ setModel(dd, [
269
272
  { value: 'r', label: 'Red' },
270
273
  { value: 'g', label: 'Green' },
271
274
  { value: 'b', label: 'Blue' },
272
- ];
275
+ ]);
273
276
  dd.selectedValue = 'b';
274
277
  expect(dd.selected).toBe(2);
275
278
  expect(dd.querySelector('.adw-drop-down-label')?.textContent).toBe('Blue');
@@ -278,7 +281,7 @@ export const GtkDropDownTest = async () => {
278
281
 
279
282
  await it('get_selected / set_selected are the GTK spelling of `selected`', async () => {
280
283
  const dd = makeDropDown();
281
- dd.model = ['a', 'b', 'c'];
284
+ setModel(dd, ['a', 'b', 'c']);
282
285
  (dd as unknown as { set_selected(n: number): void }).set_selected(2);
283
286
  expect(dd.selected).toBe(2);
284
287
  expect((dd as unknown as { get_selected(): number }).get_selected()).toBe(2);
@@ -289,7 +292,7 @@ export const GtkDropDownTest = async () => {
289
292
 
290
293
  await it('selected reflects to the attribute + marks the row', async () => {
291
294
  const dd = makeDropDown();
292
- dd.model = ['a', 'b'];
295
+ setModel(dd, ['a', 'b']);
293
296
  dd.selected = 1;
294
297
  expect(dd.getAttribute('selected')).toBe('1');
295
298
  const items = dd.querySelectorAll('.adw-drop-down-item');
@@ -298,10 +301,23 @@ export const GtkDropDownTest = async () => {
298
301
  dd.remove();
299
302
  });
300
303
 
301
- await it('selectedItem returns the descriptor', async () => {
304
+ // `selected-item` is the MODEL'S OWN item, by identity (ADR 0122 § 4) — not a
305
+ // `{ value, label }` descriptor, which is what this read used to hand back. The
306
+ // identity is the point: it is what every `selected-item` consumer compares.
307
+ await it('selectedItem is the model’s own item, by identity', async () => {
308
+ const dd = makeDropDown();
309
+ const list = setModel(dd, [{ value: 'k', label: 'Key' }]);
310
+ expect(dd.selectedItem).toBe(list.get_item(0));
311
+ expect((dd as unknown as { get_selected_item(): unknown }).get_selected_item()).toBe(list.get_item(0));
312
+ dd.remove();
313
+ });
314
+
315
+ await it('selectedItem is null with no model and with no selection', async () => {
302
316
  const dd = makeDropDown();
303
- dd.model = [{ value: 'k', label: 'Key' }];
304
- expect(dd.selectedItem).toStrictEqual({ value: 'k', label: 'Key' });
317
+ expect(dd.model).toBeNull();
318
+ expect(dd.selectedItem).toBeNull();
319
+ setModel(dd, []);
320
+ expect(dd.selectedItem).toBeNull();
305
321
  dd.remove();
306
322
  });
307
323
 
@@ -309,7 +325,7 @@ export const GtkDropDownTest = async () => {
309
325
  // The property setters can run BEFORE connectedCallback builds the DOM, where
310
326
  // _updateLabel would touch an undefined label element.
311
327
  const dd = document.createElement('gtk-drop-down') as GtkDropDown;
312
- dd.model = ['a', 'b', 'c'];
328
+ setModel(dd, ['a', 'b', 'c']);
313
329
  dd.selected = 2;
314
330
  expect(dd.isConnected).toBe(false);
315
331
  document.body.appendChild(dd);
@@ -323,7 +339,7 @@ export const GtkDropDownTest = async () => {
323
339
  await describe('gtk-drop-down notify-on-change', async () => {
324
340
  await it('a programmatic set fires notify::selected but NOT change (DOM <select> semantics)', async () => {
325
341
  const dd = makeDropDown();
326
- dd.model = ['a', 'b', 'c'];
342
+ setModel(dd, ['a', 'b', 'c']);
327
343
  let notifyCount = 0;
328
344
  let changeCount = 0;
329
345
  dd.addEventListener('notify::selected', () => notifyCount++);
@@ -346,7 +362,7 @@ export const GtkDropDownTest = async () => {
346
362
 
347
363
  await it('a popover item click selects, fires change + closes', async () => {
348
364
  const dd = makeDropDown();
349
- dd.model = ['a', 'b', 'c'];
365
+ setModel(dd, ['a', 'b', 'c']);
350
366
  let change: unknown = null;
351
367
  dd.addEventListener('change', (e) => {
352
368
  change = (e as CustomEvent).detail;
@@ -365,7 +381,7 @@ export const GtkDropDownTest = async () => {
365
381
  await describe('gtk-drop-down popover behaviour', async () => {
366
382
  await it('opens on button click and closes on outside pointerdown', async () => {
367
383
  const dd = makeDropDown();
368
- dd.model = ['a', 'b'];
384
+ setModel(dd, ['a', 'b']);
369
385
  const button = dd.querySelector('.adw-drop-down-button') as HTMLButtonElement;
370
386
  const popover = dd.querySelector('.adw-drop-down-popover') as HTMLElement;
371
387
  expect(popover.hidden).toBe(true);
@@ -381,7 +397,7 @@ export const GtkDropDownTest = async () => {
381
397
 
382
398
  await it('a disabled dropdown does not open', async () => {
383
399
  const dd = makeDropDown();
384
- dd.model = ['a', 'b'];
400
+ setModel(dd, ['a', 'b']);
385
401
  dd.setAttribute('disabled', '');
386
402
  (dd.querySelector('.adw-drop-down-button') as HTMLButtonElement).click();
387
403
  expect(dd.active).toBe(false);
@@ -392,7 +408,7 @@ export const GtkDropDownTest = async () => {
392
408
  await describe('gtk-drop-down search', async () => {
393
409
  await it('renders a search entry and filters the list', async () => {
394
410
  const dd = makeDropDown();
395
- dd.model = ['Apple', 'Banana', 'Cherry'];
411
+ setModel(dd, ['Apple', 'Banana', 'Cherry']);
396
412
  dd.enableSearch = true;
397
413
  const search = dd.querySelector('.adw-drop-down-search') as HTMLInputElement;
398
414
  expect(search).toBeTruthy();