@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/adwaita-web",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
4
4
  "description": "Adwaita/Libadwaita web components for browser targets",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -45,14 +45,14 @@
45
45
  "@codemirror/language": "^6.12.4",
46
46
  "@codemirror/state": "^6.7.0",
47
47
  "@codemirror/view": "^6.43.4",
48
- "@gjsify/adwaita-core": "^0.47.0",
49
- "@gjsify/adwaita-fonts": "^0.47.0",
50
- "@gjsify/adwaita-icons": "^0.47.0",
48
+ "@gjsify/adwaita-core": "^0.49.0",
49
+ "@gjsify/adwaita-fonts": "^0.49.0",
50
+ "@gjsify/adwaita-icons": "^0.49.0",
51
51
  "@lezer/highlight": "^1.2.3"
52
52
  },
53
53
  "devDependencies": {
54
- "@gjsify/cli": "^0.47.0",
55
- "@gjsify/unit": "^0.47.0",
54
+ "@gjsify/cli": "^0.49.0",
55
+ "@gjsify/unit": "^0.49.0",
56
56
  "sass": "^1.101.0",
57
57
  "typescript": "^6.0.3"
58
58
  },
@@ -188,3 +188,102 @@ gtk-popover {
188
188
  font-weight: 700;
189
189
  opacity: var(--dim-opacity);
190
190
  }
191
+
192
+ // --- The menu INSIDE the popover (ADR 0042) -----------------------------------
193
+ //
194
+ // One rule set for both menu-bearing elements, because the split button's dropdown
195
+ // half IS a GtkMenuButton and its popup is the same `popover.menu`. SIX nodes, none of
196
+ // which has a counterpart in the port's previous menus — they drew a flat list of labels
197
+ // and nothing else: the separator, the section title, the accelerator, the check/radio
198
+ // mark (plus the two nested `[aria-checked='true']` rules that draw it), the submenu
199
+ // chevron and the back row.
200
+
201
+ // `popover.menu separator { margin: 6px 0 }` (_menus.scss:68-70). GTK's separator is
202
+ // a real widget with its own background; in the DOM the hairline is the border, so
203
+ // the 6px is split above and below it rather than being a margin on a 1px box.
204
+ .adw-popover-separator {
205
+ height: 1px;
206
+ margin: 6px 0;
207
+ background-color: var(--separator-color, rgba(0, 0, 0, 0.15));
208
+ }
209
+
210
+ // A section's heading. `.adw-popover-title` already carries the weight and the dim;
211
+ // this is the tighter top inset a heading needs when it follows a separator rather
212
+ // than opening the menu.
213
+ .adw-popover-section-title {
214
+ padding-top: 2px;
215
+ }
216
+
217
+ // `popover.menu accelerator { color: dim; margin-left: $menu_padding }`
218
+ // (_menus.scss:78-82). `margin-inline-start` rather than `margin-left`, so it follows
219
+ // the writing direction the way GTK's `:dir(ltr)`/`:dir(rtl)` pair does — the two
220
+ // rules are one logical property here.
221
+ .adw-popover-item-accel {
222
+ margin-inline-start: auto;
223
+ padding-inline-start: 12px;
224
+ opacity: var(--dim-opacity);
225
+ font-size: 0.9em;
226
+ }
227
+
228
+ // `popover.menu check, radio { min-width: 14px; min-height: 14px; padding: 2px }`
229
+ // (_menus.scss:85-90). The slot is reserved whether or not the row is checked, so the
230
+ // labels of a group line up — the reason it is a node and not a `::before`.
231
+ //
232
+ // A DELIBERATE SUBSTITUTION, the same kind `<adw-split-button>`'s pan-up arrow already
233
+ // documents: GTK draws the tick with `object-select-symbolic`, and this port's icon set
234
+ // ships no checkmark (`_icons.generated.scss` has 42 glyphs and none of them is one). A
235
+ // two-sided rotated border is the tick, so nothing here depends on an asset that would
236
+ // silently render as `image-missing`.
237
+ .adw-popover-item-check {
238
+ position: relative;
239
+ box-sizing: border-box;
240
+ flex: none;
241
+ width: 14px;
242
+ height: 14px;
243
+ padding: 2px;
244
+
245
+ // Drawn off `aria-checked`, never off a class of its own: the accessible state and
246
+ // the visible state are then the same fact rather than two that can disagree.
247
+ [aria-checked='true'] > &::after {
248
+ content: '';
249
+ position: absolute;
250
+ inset-inline-start: 4px;
251
+ top: 1px;
252
+ width: 4px;
253
+ height: 8px;
254
+ border: solid currentcolor;
255
+ border-width: 0 2px 2px 0;
256
+ transform: rotate(45deg);
257
+ }
258
+
259
+ // A radio's mark is a filled dot, so the tick becomes a disc.
260
+ &.radio {
261
+ border-radius: 9999px;
262
+ }
263
+
264
+ [aria-checked='true'] > &.radio::after {
265
+ inset-inline-start: 4px;
266
+ top: 4px;
267
+ width: 6px;
268
+ height: 6px;
269
+ border: none;
270
+ border-radius: 9999px;
271
+ background-color: currentcolor;
272
+ transform: none;
273
+ }
274
+ }
275
+
276
+ // The chevron on a row that opens a submenu.
277
+ .adw-popover-item-arrow {
278
+ margin-inline-start: auto;
279
+ width: 16px;
280
+ height: 16px;
281
+ flex: none;
282
+ opacity: var(--dim-opacity);
283
+ }
284
+
285
+ // The row that leaves a submenu. It carries the parent's own label, so it reads as
286
+ // the heading of the page it returns to rather than as a bare "Back".
287
+ .adw-popover-back {
288
+ font-weight: 700;
289
+ }
@@ -13,6 +13,7 @@ import { AVATAR_COLORS, avatarMaxFontSize } from '@gjsify/adwaita-core';
13
13
  import {
14
14
  AVATAR_COLOR_VECTORS,
15
15
  AVATAR_FONT_SIZE_VECTORS,
16
+ AVATAR_ICON_SIZE_VECTORS,
16
17
  AVATAR_INITIALS_VECTORS,
17
18
  } from '@gjsify/adwaita-core/conformance';
18
19
 
@@ -124,6 +125,22 @@ export const AdwAvatarTest = async () => {
124
125
  });
125
126
  });
126
127
 
128
+ await describe('adw-avatar fallback glyph size (adw-avatar.c:756)', async () => {
129
+ for (const { size, iconSize } of AVATAR_ICON_SIZE_VECTORS) {
130
+ await it(`size ${size} draws the glyph at ${iconSize}`, () => {
131
+ // Through the ELEMENT, not the helper: the box was hand-typed here as
132
+ // `round(size * 0.55)` and drew ~10% larger than GTK at every size,
133
+ // with nothing in a position to say so.
134
+ const { avatar, host } = mountAvatar('Ada Lovelace', false);
135
+ avatar.setAttribute('size', String(size));
136
+ const icon = avatar.querySelector('.adw-avatar-icon') as HTMLElement;
137
+ expect(icon.hidden).toBe(false);
138
+ expect(icon.getAttribute('size')).toBe(String(iconSize));
139
+ host.remove();
140
+ });
141
+ }
142
+ });
143
+
127
144
  await describe('adw-avatar mode (Adw.Avatar update_visibility)', async () => {
128
145
  await it('keeps a whitespace-only name in initials mode with a blank label', () => {
129
146
  const { avatar, host } = mountAvatar(' ');
@@ -9,7 +9,19 @@
9
9
  // display silently.
10
10
  import { describe, expect, it } from '@gjsify/unit';
11
11
 
12
- import { COMBO_CHOOSER_VECTORS } from '@gjsify/adwaita-core/conformance';
12
+ import {
13
+ ADJUSTMENT_AUTHORED_VECTORS,
14
+ ADJUSTMENT_PARSE_VECTORS,
15
+ COMBO_CHOOSER_VECTORS,
16
+ COMBO_SELECTION_VECTORS,
17
+ LIST_ITEMS_CHANGED_VECTORS,
18
+ LIST_MODEL_OWNERSHIP_VECTORS,
19
+ LIST_NORMALIZE_VECTORS,
20
+ LIST_PARSE_VECTORS,
21
+ LIST_SELECTION_CLAMP_VECTORS,
22
+ applyListReadback,
23
+ } from '@gjsify/adwaita-core/conformance';
24
+ import type { ComboSelectionStep } from '@gjsify/adwaita-core/conformance';
13
25
 
14
26
  import type { AdwComboRow } from './elements/adw-combo-row.js';
15
27
  import type { AdwExpanderRow } from './elements/adw-expander-row.js';
@@ -32,11 +44,45 @@ function record(el: HTMLElement, event: string): { details: unknown[]; stop: ()
32
44
  return { details, stop: () => el.removeEventListener(event, listener) };
33
45
  }
34
46
 
47
+ /**
48
+ * Replay one `COMBO_SELECTION_VECTORS` step against a live `<adw-combo-row>` — the
49
+ * property sets a consumer has for the programmatic ops, a real `<select>` pick for the
50
+ * interactive one.
51
+ *
52
+ * The row can drive the table at all only because the model and the select-by-value are
53
+ * 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.
58
+ */
59
+ function applyComboStep(row: AdwComboRow, step: ComboSelectionStep): void {
60
+ const select = row.querySelector('select') as HTMLSelectElement;
61
+ switch (step.op) {
62
+ case 'setModel':
63
+ row.model = step.model;
64
+ return;
65
+ case 'setSelectedIndex':
66
+ row.selected = step.index;
67
+ return;
68
+ case 'setSelectedValue':
69
+ row.selectedValue = step.value;
70
+ return;
71
+ case 'select':
72
+ // A dismissed chooser: the native dropdown closes with nothing picked, which
73
+ // dispatches no `change` at all — there is no gesture to replay.
74
+ if (step.index < 0) return;
75
+ select.selectedIndex = step.index;
76
+ select.dispatchEvent(new Event('change'));
77
+ return;
78
+ }
79
+ }
80
+
35
81
  export const AdwRowStateTest = async () => {
36
82
  await describe('adw-combo-row (ComboState)', async () => {
37
83
  await it('renders the selected label and notifies only on a user pick', async () => {
38
84
  const { el: row, host } = parse<AdwComboRow>(
39
- `<adw-combo-row title="Colour" items='["Red","Green","Blue"]' selected="1"></adw-combo-row>`,
85
+ `<adw-combo-row title="Colour" model='["Red","Green","Blue"]' selected="1"></adw-combo-row>`,
40
86
  'adw-combo-row',
41
87
  );
42
88
  const select = row.querySelector('select') as HTMLSelectElement;
@@ -68,7 +114,7 @@ export const AdwRowStateTest = async () => {
68
114
 
69
115
  await it('renders an empty value for an out-of-range selection', async () => {
70
116
  const { el: row, host } = parse<AdwComboRow>(
71
- `<adw-combo-row title="Empty" items='[]' selected="3"></adw-combo-row>`,
117
+ `<adw-combo-row title="Empty" model='[]' selected="3"></adw-combo-row>`,
72
118
  'adw-combo-row',
73
119
  );
74
120
  expect((row.querySelector('.adw-row-value') as HTMLSpanElement).textContent).toBe('');
@@ -79,7 +125,7 @@ export const AdwRowStateTest = async () => {
79
125
  await it(`${count} option(s) → ${presentsChooser ? 'a chooser' : 'not a chooser'}: ${rule}`, async () => {
80
126
  const items = JSON.stringify(Array.from({ length: count }, (_, i) => `Item ${i}`));
81
127
  const { el: row, host } = parse<AdwComboRow>(
82
- `<adw-combo-row title="Pick" items='${items}'></adw-combo-row>`,
128
+ `<adw-combo-row title="Pick" model='${items}'></adw-combo-row>`,
83
129
  'adw-combo-row',
84
130
  );
85
131
  const value = row.querySelector('.adw-row-value') as HTMLSpanElement;
@@ -101,31 +147,302 @@ export const AdwRowStateTest = async () => {
101
147
 
102
148
  await it('gains its arrow back when the model grows past one item', async () => {
103
149
  const { el: row, host } = parse<AdwComboRow>(
104
- `<adw-combo-row title="Pick" items='["Only"]'></adw-combo-row>`,
150
+ `<adw-combo-row title="Pick" model='["Only"]'></adw-combo-row>`,
105
151
  'adw-combo-row',
106
152
  );
107
153
  const select = row.querySelector('select') as HTMLSelectElement;
108
154
  expect(select.disabled).toBe(true);
109
155
 
110
- // `items` is read at connect only, so the model is replaced through the state
111
- // the element composes — the same path a consumer has.
112
- (
113
- row as unknown as { _state: { setOptions(o: { label: string; value: string }[]): void } }
114
- )._state.setOptions([
115
- { label: 'One', value: 'a' },
116
- { label: 'Two', value: 'b' },
117
- ]);
156
+ // Through the PUBLISHED path a consumer has — the same `model` property
157
+ // `<gtk-drop-down>` publishes, not a reach into the composed state.
158
+ row.model = ['One', 'Two'];
118
159
  expect(select.disabled).toBe(false);
119
160
  expect(row.dataset.presentsChooser).toBe('true');
120
161
 
121
162
  host.remove();
122
163
  });
164
+
165
+ // The model is INPUT, so replacing it after connect has to reach the DOM — the
166
+ // half `<gtk-drop-down>` has had all along and this row did not: it parsed its
167
+ // items once in `connectedCallback`, observed `['title','subtitle','selected']`,
168
+ // and published no accessor, so `row.items = […]` wrote an expando onto the
169
+ // element and `setAttribute('items', …)` reached no callback (#1525). Both doors
170
+ // are asserted because a consumer reaches for whichever its framework binds.
171
+ await it('re-renders the options when the model is replaced after connect', async () => {
172
+ const { el: row, host } = parse<AdwComboRow>(
173
+ `<adw-combo-row title="Colour" model='["Red","Green"]'></adw-combo-row>`,
174
+ 'adw-combo-row',
175
+ );
176
+ const select = row.querySelector('select') as HTMLSelectElement;
177
+ const value = row.querySelector('.adw-row-value') as HTMLSpanElement;
178
+ expect(select.options.length).toBe(2);
179
+
180
+ // The PROPERTY, with the plain-string vocabulary both selectors accept.
181
+ row.model = ['Cyan', 'Magenta', 'Yellow'];
182
+ 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');
184
+ // The model was replaced under an index the new one still has, so autoselect
185
+ // keeps 0 and the inline label has to follow the NEW option at that index.
186
+ expect(value.textContent).toBe('Cyan');
187
+
188
+ // The ATTRIBUTE, the spelling markup and every attribute-binding framework use.
189
+ row.setAttribute('model', '["Only"]');
190
+ expect([...select.options].map((o) => o.textContent).join('|')).toBe('Only');
191
+ expect(value.textContent).toBe('Only');
192
+ // …and the chooser rule re-runs off the replaced model.
193
+ expect(select.disabled).toBe(true);
194
+ expect(row.dataset.presentsChooser).toBe('false');
195
+
196
+ host.remove();
197
+ });
198
+
199
+ // One spelling on both selectors — `model`, `Adw.ComboRow`'s own property name
200
+ // (ADR 0046) — so a consumer moving a model between them renames nothing.
201
+ await it('accepts descriptor objects, and selection by value', async () => {
202
+ const { el: row, host } = parse<AdwComboRow>(
203
+ `<adw-combo-row title="Colour"></adw-combo-row>`,
204
+ 'adw-combo-row',
205
+ );
206
+ const select = row.querySelector('select') as HTMLSelectElement;
207
+
208
+ row.model = [
209
+ { value: 'r', label: 'Red' },
210
+ { value: 'g', label: 'Green' },
211
+ ];
212
+ expect([...select.options].map((o) => o.textContent).join('|')).toBe('Red|Green');
213
+ expect(row.model.map((o) => o.value).join('|')).toBe('r|g');
214
+
215
+ // And selection by VALUE, the drop-down's other published setter.
216
+ row.selectedValue = 'g';
217
+ expect(row.selected).toBe(1);
218
+ expect((row.querySelector('.adw-row-value') as HTMLSpanElement).textContent).toBe('Green');
219
+
220
+ host.remove();
221
+ });
222
+
223
+ // The row publishes the drop-down's property set apart from `enableSearch` and
224
+ // `active`, which are that element's popover chrome. `selectedItem` is NOT chrome —
225
+ // 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 () => {
228
+ const { el: row, host } = parse<AdwComboRow>(
229
+ `<adw-combo-row title="Pick"></adw-combo-row>`,
230
+ 'adw-combo-row',
231
+ );
232
+
233
+ expect(row.selectedItem).toBeNull();
234
+
235
+ row.model = [
236
+ { value: 'r', label: 'Red' },
237
+ { value: 'g', label: 'Green' },
238
+ ];
239
+ expect(row.selectedItem?.value).toBe('r');
240
+ row.selected = 1;
241
+ expect(row.selectedItem?.label).toBe('Green');
242
+
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.
247
+ 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('');
252
+
253
+ host.remove();
254
+ });
255
+ });
256
+
257
+ // The portable list model (ADR 0046), driven through this row's own `model` door.
258
+ //
259
+ // 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
262
+ // model, the labels or the selection can see. Before the splice landed, every row with
263
+ // a non-zero `survivors` scored 0.
264
+ await describe('adw-combo-row list model (portable list-model vectors)', async () => {
265
+ for (const vector of LIST_NORMALIZE_VECTORS) {
266
+ await it(`normalize — ${vector.rule}`, async () => {
267
+ const { el: row, host } = parse<AdwComboRow>(
268
+ `<adw-combo-row title="Pick"></adw-combo-row>`,
269
+ 'adw-combo-row',
270
+ );
271
+ row.model = vector.input;
272
+ expect(row.model).toStrictEqual([...vector.model]);
273
+ const select = row.querySelector('select') as HTMLSelectElement;
274
+ expect([...select.options].map((option) => option.textContent)).toStrictEqual(
275
+ vector.model.map((item) => item.label),
276
+ );
277
+ host.remove();
278
+ });
279
+ }
280
+
281
+ for (const vector of LIST_PARSE_VECTORS) {
282
+ await it(`attribute — ${vector.rule}`, async () => {
283
+ const { el: row, host } = parse<AdwComboRow>(
284
+ `<adw-combo-row title="Pick"></adw-combo-row>`,
285
+ 'adw-combo-row',
286
+ );
287
+ // Set AFTER connect, so the attribute reaches the callback rather than the
288
+ // seeding path: a total parser has to be total on the live door too.
289
+ row.setAttribute('model', vector.attribute);
290
+ expect(row.model).toStrictEqual([...vector.model]);
291
+ host.remove();
292
+ });
293
+ }
294
+
295
+ for (const vector of LIST_ITEMS_CHANGED_VECTORS) {
296
+ await it(`items-changed — ${vector.rule}`, async () => {
297
+ const { el: row, host } = parse<AdwComboRow>(
298
+ `<adw-combo-row title="Pick"></adw-combo-row>`,
299
+ 'adw-combo-row',
300
+ );
301
+ const select = row.querySelector('select') as HTMLSelectElement;
302
+ row.model = vector.previous;
303
+ const before = [...select.options];
304
+
305
+ row.model = vector.next;
306
+
307
+ const after = [...select.options];
308
+ expect(after.map((option) => option.textContent)).toStrictEqual(vector.next.map((item) => item.label));
309
+ expect(before.filter((option) => after.includes(option)).length).toBe(vector.survivors);
310
+ host.remove();
311
+ });
312
+ }
313
+
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.
318
+ for (const vector of LIST_MODEL_OWNERSHIP_VECTORS) {
319
+ await it(`ownership — ${vector.rule}`, async () => {
320
+ const { el: row, host } = parse<AdwComboRow>(
321
+ `<adw-combo-row title="Pick"></adw-combo-row>`,
322
+ 'adw-combo-row',
323
+ );
324
+ const select = row.querySelector('select') as HTMLSelectElement;
325
+ const input = vector.initial.map((item) => ({ ...item }));
326
+ row.model = input;
327
+ const before = [...select.options];
328
+
329
+ const mutated = applyListReadback(vector.source === 'input' ? input : row.model, vector.op);
330
+ if (vector.assign) row.model = mutated;
331
+
332
+ expect(row.model).toStrictEqual([...vector.model]);
333
+ const after = [...select.options];
334
+ expect(after.map((option) => option.textContent)).toStrictEqual(vector.model.map((item) => item.label));
335
+ expect(before.filter((option) => after.includes(option)).length).toBe(vector.survivors);
336
+ host.remove();
337
+ });
338
+ }
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
+ });
360
+
361
+ // The SAME table `<gtk-drop-down>` and the NativeScript renderer are held to
362
+ // (`COMBO_SELECTION_VECTORS`), replayed through this row's own DOM and read back as
363
+ // what a user would see: the inline value label, `selected`/`selectedValue`, and the
364
+ // `notify::selected` stream. Two of the four step ops had no spelling here until the
365
+ // model and the select-by-value were published, which is what the conformance header
366
+ // names as the condition for this row inheriting the rows.
367
+ await describe('adw-combo-row selection (libadwaita conformance vectors)', async () => {
368
+ for (const vector of COMBO_SELECTION_VECTORS) {
369
+ await it(`${vector.name} — ${vector.rule}`, async () => {
370
+ const { el: row, host } = parse<AdwComboRow>(
371
+ `<adw-combo-row title="Pick"></adw-combo-row>`,
372
+ 'adw-combo-row',
373
+ );
374
+ const value = row.querySelector('.adw-row-value') as HTMLSpanElement;
375
+ const events = record(row, 'notify::selected');
376
+
377
+ for (const step of vector.steps) applyComboStep(row, step);
378
+
379
+ expect(row.selected).toBe(vector.selected);
380
+ expect(row.selectedValue).toBe(vector.value);
381
+ expect(value.textContent).toBe(vector.label);
382
+ // `notify::selected` is this row's DOM spelling of the `interactive`
383
+ // flag: a user pick emits, a programmatic set repaints silently. So the
384
+ // interactive frames of the state's stream ARE the events a listener sees.
385
+ expect(events.details).toStrictEqual(
386
+ vector.emitted
387
+ .filter((change) => change.interactive)
388
+ .map((change) => ({ selected: change.selected })),
389
+ );
390
+
391
+ events.stop();
392
+ host.remove();
393
+ });
394
+ }
123
395
  });
124
396
 
125
397
  await describe('adw-spin-row (SpinState)', async () => {
398
+ // THE SHARED TABLES, driven through the ELEMENT and its markup door — the same rows
399
+ // the core suite and the NativeScript suite assert, so a range authored in one
400
+ // dialect means the same thing in all three (ADR 0047).
401
+ for (const vector of ADJUSTMENT_AUTHORED_VECTORS) {
402
+ await it(`authored: ${vector.rule}`, () => {
403
+ const { el: row, host } = parse<AdwSpinRow>(
404
+ `<adw-spin-row title="Amount" adjustment='${JSON.stringify(vector.input)}'></adw-spin-row>`,
405
+ 'adw-spin-row',
406
+ );
407
+ expect(row.adjustment).toStrictEqual({ ...vector.adjustment });
408
+ expect(row.value).toBe(vector.adjustment.value);
409
+ host.remove();
410
+ });
411
+ }
412
+
413
+ // The state the parse rows are written over: the defaults with a value of 7, so a
414
+ // door that answered with a WHOLE adjustment would take the value back to 0 — which
415
+ // is what this table's `{"upper":20}` row exists to catch.
416
+ const SEEDED = { value: 7, lower: 0, upper: 100, stepIncrement: 1, pageIncrement: 1, pageSize: 0 };
417
+
418
+ for (const vector of ADJUSTMENT_PARSE_VECTORS) {
419
+ await it(`attribute: ${vector.rule}`, () => {
420
+ // Seeded through the ATTRIBUTE and not left at the defaults, so the write
421
+ // below has something to overwrite: a row that starts where it ends cannot
422
+ // tell a door that read the vector from one that was never called.
423
+ const { el: row, host } = parse<AdwSpinRow>(
424
+ `<adw-spin-row title="Amount" value="7" adjustment='{"upper":40}'></adw-spin-row>`,
425
+ 'adw-spin-row',
426
+ );
427
+ expect(row.adjustment).toStrictEqual({ ...SEEDED, upper: 40 });
428
+
429
+ // `null` is the ABSENT attribute, and removing one is how an element meets
430
+ // it: `attributeChangedCallback` fires with a null value, which the door has
431
+ // to answer as "nothing authored" rather than as a whole adjustment. The
432
+ // element never sees that call if the test simply skips the write.
433
+ if (vector.raw === null) row.removeAttribute('adjustment');
434
+ else row.setAttribute('adjustment', vector.raw);
435
+
436
+ // The authored fields over what the row already held, and nothing else —
437
+ // `upper: 40` survives every row that does not name an upper.
438
+ expect(row.adjustment).toStrictEqual({ ...SEEDED, upper: 40, ...vector.input });
439
+ host.remove();
440
+ });
441
+ }
442
+
126
443
  await it('steps at step precision and notifies only on a stepper press', async () => {
127
444
  const { el: row, host } = parse<AdwSpinRow>(
128
- '<adw-spin-row title="Amount" min="0" max="10" step="0.1" value="0"></adw-spin-row>',
445
+ `<adw-spin-row title="Amount" value="0" adjustment='{"lower":0,"upper":10,"stepIncrement":0.1}'></adw-spin-row>`,
129
446
  'adw-spin-row',
130
447
  );
131
448
  const input = row.querySelector('input') as HTMLInputElement;
@@ -154,7 +471,7 @@ export const AdwRowStateTest = async () => {
154
471
 
155
472
  await it('clamps to the lower bound and re-clamps when a bound moves', async () => {
156
473
  const { el: row, host } = parse<AdwSpinRow>(
157
- '<adw-spin-row title="Amount" min="2" max="8" step="1" value="5"></adw-spin-row>',
474
+ `<adw-spin-row title="Amount" value="5" adjustment='{"lower":2,"upper":8}'></adw-spin-row>`,
158
475
  'adw-spin-row',
159
476
  );
160
477
  const input = row.querySelector('input') as HTMLInputElement;
@@ -162,9 +479,10 @@ export const AdwRowStateTest = async () => {
162
479
  row.value = -4;
163
480
  expect(row.value).toBe(2);
164
481
 
165
- // Moving the upper bound below the value pulls the value into range.
482
+ // Moving the upper bound below the value pulls the value into range — and the
483
+ // attribute names ONLY the bound, so the value it does not name is not reset.
166
484
  row.value = 8;
167
- row.setAttribute('max', '6');
485
+ row.setAttribute('adjustment', '{"upper":6}');
168
486
  expect(row.value).toBe(6);
169
487
  expect(input.value).toBe('6');
170
488
 
@@ -28,7 +28,7 @@ export const AdwTabViewTest = async () => {
28
28
  await it('builds one tab per page and shows the first page', async () => {
29
29
  const view = makeTabView();
30
30
  expect(view.querySelectorAll('.adw-tab').length).toBe(3);
31
- expect(view.selected).toBe(0);
31
+ expect(view.selectedIndex).toBe(0);
32
32
  const pages = view.querySelectorAll('.adw-tab-page');
33
33
  expect((pages[0] as HTMLElement).hidden).toBe(false);
34
34
  expect((pages[1] as HTMLElement).hidden).toBe(true);
@@ -42,15 +42,18 @@ export const AdwTabViewTest = async () => {
42
42
  notified = (event as CustomEvent).detail.selected;
43
43
  });
44
44
  (view.querySelectorAll('.adw-tab')[2] as HTMLButtonElement).click();
45
- expect(view.selected).toBe(2);
45
+ expect(view.selectedIndex).toBe(2);
46
46
  expect(notified).toBe(2);
47
47
  expect((view.querySelectorAll('.adw-tab-page')[2] as HTMLElement).hidden).toBe(false);
48
48
  view.parentElement?.remove();
49
49
  });
50
50
 
51
- await it('setting the selected attribute switches pages', async () => {
51
+ await it('setting the selected-page attribute switches pages', async () => {
52
52
  const view = makeTabView();
53
- view.setAttribute('selected', '1');
53
+ // ADR 0048: the markup door names the PAGE. `makeTabView` declares no
54
+ // `page-id`, so the ids are the generated ones the element reflects back.
55
+ const second = view.pages[1]!.id;
56
+ view.setAttribute('selected-page', second);
54
57
  expect((view.querySelectorAll('.adw-tab-page')[1] as HTMLElement).hidden).toBe(false);
55
58
  expect((view.querySelectorAll('.adw-tab-page')[0] as HTMLElement).hidden).toBe(true);
56
59
  view.parentElement?.remove();
@@ -70,7 +73,7 @@ export const AdwTabViewTest = async () => {
70
73
  expect(view.querySelectorAll('.adw-tab').length).toBe(2);
71
74
  // Closing a page BEFORE the selection must not also select the tab,
72
75
  // and must leave the selected PAGE selected.
73
- expect(view.selected).toBe(0);
76
+ expect(view.selectedIndex).toBe(0);
74
77
  view.parentElement?.remove();
75
78
  });
76
79
  });
@@ -19,7 +19,14 @@
19
19
  // (adw-avatar.c:117-125) — the branch that wins over both others — could not be reached,
20
20
  // and the vector row covering it had nothing to drive.
21
21
 
22
- import { avatarColor, avatarFontSize, avatarInitials, avatarMaxFontSize, avatarMode } from '@gjsify/adwaita-core';
22
+ import {
23
+ avatarColor,
24
+ avatarFontSize,
25
+ avatarIconSize,
26
+ avatarInitials,
27
+ avatarMaxFontSize,
28
+ avatarMode,
29
+ } from '@gjsify/adwaita-core';
23
30
 
24
31
  import { type GtkImage, createGtkImage } from './gtk-image.js';
25
32
 
@@ -100,8 +107,9 @@ export class AdwAvatar extends HTMLElement {
100
107
  this._iconEl.iconName = this.getAttribute('icon') ?? 'avatar-default';
101
108
  // The glyph scales with the avatar: `size` sizes the BOX and the mask
102
109
  // together, which is what `.adw-avatar-icon`'s `mask-size: contain`
103
- // was there to approximate.
104
- this._iconEl.size = Math.round(size * 0.55);
110
+ // was there to approximate. The factor moved into the core when the
111
+ // NativeScript avatar grew the same fallback — one number, two renderers.
112
+ this._iconEl.size = avatarIconSize(size);
105
113
  this._iconEl.hidden = false;
106
114
  }
107
115
  }