@gjsify/adwaita-web 0.48.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.
@@ -23,13 +23,19 @@ export declare class AdwTabView extends HTMLElement {
23
23
  private readonly _hovered;
24
24
  private _initialized;
25
25
  private _generatedIds;
26
- /** Guards the `selected` attribute reflection against re-entering the model. */
26
+ /** Guards the `selected-page` attribute reflection against re-entering the model. */
27
27
  private _reflecting;
28
28
  static get observedAttributes(): string[];
29
29
  constructor();
30
- /** Zero-based index of the visible page, `-1` when the view is empty. */
31
- get selected(): number;
32
- set selected(value: number);
30
+ /**
31
+ * The selected page (`Adw.TabView:selected-page`), `null` when the view is empty.
32
+ *
33
+ * THE PAGE, NOT ITS POSITION (ADR 0048). Its position is {@link selectedIndex}, which
34
+ * stays a read-only report; `Adw.TabView` selects by object, and the model has held
35
+ * that object all along.
36
+ */
37
+ get selectedPage(): AdwTabViewPage | null;
38
+ set selectedPage(page: AdwTabViewPage | null);
33
39
  /**
34
40
  * Whether the tab bar hides itself when it has nothing to show —
35
41
  * `tabsRevealed`, which keeps the bar up for a single PINNED tab.
@@ -57,7 +63,8 @@ export declare class AdwTabView extends HTMLElement {
57
63
  get selectedIndex(): number;
58
64
  get selectedContent(): HTMLElement | null;
59
65
  isClosing(id: string): boolean;
60
- setSelectedPage(id: string | null): boolean;
66
+ /** `adw_tab_view_set_selected_page`, which takes the PAGE; an id is accepted too. */
67
+ setSelectedPage(page: AdwTabViewPage | string | null): boolean;
61
68
  selectNthPage(n: number): boolean;
62
69
  selectPreviousPage(): boolean;
63
70
  selectNextPage(): boolean;
@@ -142,6 +149,17 @@ export declare class AdwTabView extends HTMLElement {
142
149
  private _scrollSelectedTabIntoBar;
143
150
  private _applyBarVisibility;
144
151
  private _reflectSelected;
152
+ /**
153
+ * `selected-page="<page id>"` — the markup half of {@link selectedPage} (ADR 0048).
154
+ *
155
+ * The id is the one `<adw-tab-page page-id>` declares; a page that declares none gets
156
+ * a generated one, which an author cannot predict and therefore cannot select by. That
157
+ * is the same bargain `<adw-view-stack visible-child-name>` already makes for a page
158
+ * with no name, and it is why `page-id` exists.
159
+ *
160
+ * An id no page holds is IGNORED rather than treated as the first page — libadwaita
161
+ * refuses a foreign `AdwTabPage` outright, and `setSelectedPage` is the refusal here.
162
+ */
145
163
  private _applySelectedAttribute;
146
164
  /**
147
165
  * The `Adw.TabView` shortcut table (`init_shortcuts`), plus the ArrowLeft/ArrowRight
@@ -6,9 +6,24 @@ export declare class AdwViewStack extends HTMLElement {
6
6
  static get observedAttributes(): string[];
7
7
  constructor();
8
8
  get pages(): readonly AdwViewStackPageInfo[];
9
- /** Zero-based index of the visible page, `-1` when nothing is selected. */
9
+ /**
10
+ * Zero-based position of the visible page, `-1` when nothing is selected.
11
+ *
12
+ * A REPORT, NOT A DOOR (ADR 0048). `Adw.ViewStack` selects by NAME, and an ordinal is
13
+ * derived — an insertion before the visible page increments it, a reorder rewrites it,
14
+ * a removal decrements it. Selecting is {@link visibleChildName}.
15
+ */
10
16
  get visibleChildIndex(): number;
11
- set visibleChildIndex(value: number);
17
+ /**
18
+ * Show the page at position `n`. Returns whether the selection moved.
19
+ *
20
+ * THE ORDINAL IS A CALL, NOT A PROPERTY (ADR 0048): it resolves against the page list
21
+ * as it is now and keeps nothing, where a held index comes to mean another page after
22
+ * an insert, a reorder or a removal. Port-owned — `refs/libadwaita` gives
23
+ * `AdwViewStack` no ordinal API — and named for the tab view's existing
24
+ * `selectNthPage`. The guard on `n` is the core's and is pinned by the shared vectors.
25
+ */
26
+ selectNthPage(n: number): boolean;
12
27
  get visibleChildName(): string;
13
28
  set visibleChildName(name: string | null | undefined);
14
29
  get visibleChild(): HTMLElement | null;
@@ -0,0 +1,2 @@
1
+ import '@gjsify/adwaita-web';
2
+ export declare const AdwSharedTreesTest: () => Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/adwaita-web",
3
- "version": "0.48.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.48.0",
49
- "@gjsify/adwaita-fonts": "^0.48.0",
50
- "@gjsify/adwaita-icons": "^0.48.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.48.0",
55
- "@gjsify/unit": "^0.48.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
  },
@@ -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(' ');
@@ -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
  }
@@ -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 };
@@ -0,0 +1,151 @@
1
+ // ONE AUTHORED TREE, BUILT BY THIS RENDERER — ADR 0051, and the renderer ADR 0027 § 9
2
+ // names by name: "the same authored tree, rendered through this host and through
3
+ // `adwaita-web`, satisfies the same `@gjsify/adwaita-core/conformance` vectors with no
4
+ // per-surface markup branch".
5
+ //
6
+ // The sibling driver is `packages/framework/gtk-host/src/shared-trees.spec.ts`. One corpus
7
+ // — `scripts/adwaita-gallery-shared-trees.mjs`, read here and not transcribed — one set of
8
+ // expectations, a driver per renderer: ADR 0030's shape one level up, so a green-here /
9
+ // red-there diff is attributable to the RENDERER and not to two suites disagreeing about
10
+ // what they test.
11
+ //
12
+ // TWO DECLARED TRANSFORMS ON THE WAY IN, and no third. `hostTagOf` turns the authored GIR
13
+ // class name into the element name, which is the same case rule `gtk-host` stamps its tags
14
+ // with; and an authored property name becomes its kebab-case ATTRIBUTE, because this
15
+ // renderer's door is markup. Both are total functions over the corpus with no tag list and
16
+ // no per-block case — a `switch` on a widget name here would be the per-surface branch
17
+ // § 9 forbids, and the reason the corpus admits a block only when it needs no alias.
18
+ //
19
+ // The readers below go through the REAL DOM the element rendered, never a state object of
20
+ // its own: an element asserting against its own bookkeeping agrees with itself while the
21
+ // page is wrong.
22
+ //
23
+ // What is NOT here is everything that is not about this renderer — which tables the corpus
24
+ // reaches, which block is declared to reach none, whether an expectation's address exists.
25
+ // Those are facts about the CORPUS and are asserted once, in `adwaita-core`'s own suite,
26
+ // which runs on Node as well as in a browser.
27
+
28
+ import { describe, expect, it } from '@gjsify/unit';
29
+
30
+ import {
31
+ authoredTags,
32
+ sharedTreeExpectations,
33
+ subjectIndexOf,
34
+ type SharedTreeExpectation,
35
+ type SharedTreeNode,
36
+ } from '@gjsify/adwaita-core/conformance';
37
+
38
+ // `attributeOf` — `buttonLabel` -> `button-label` — comes from the corpus rather than
39
+ // being spelled again here, because arm 13 of `check-generated-website-data.mjs` reads
40
+ // gallery fences back through the SAME rule: two copies would let this driver and that
41
+ // check agree with each other while the fence a reader copies means something else.
42
+ import {
43
+ ADWAITA_GALLERY_SHARED_TREES,
44
+ attributeOf,
45
+ hostTagOf,
46
+ } from '../../../../scripts/adwaita-gallery-shared-trees.mjs';
47
+
48
+ import '@gjsify/adwaita-web';
49
+
50
+ /**
51
+ * The whole renderer-specific half of this driver: an element, its authored properties as
52
+ * attributes, its children, in that order.
53
+ *
54
+ * A boolean is the ATTRIBUTE'S PRESENCE, which is what every element here reads
55
+ * (`hasAttribute('revealed')`, `hasAttribute('expanded')`) — spelling `"true"` would set a
56
+ * present attribute for `false` as well.
57
+ */
58
+ function build(node: SharedTreeNode): HTMLElement {
59
+ const el = document.createElement(hostTagOf(node.tag));
60
+ for (const [prop, value] of Object.entries(node.props ?? {})) {
61
+ if (typeof value === 'boolean') el.toggleAttribute(attributeOf(prop), value);
62
+ else el.setAttribute(attributeOf(prop), String(value));
63
+ }
64
+ for (const child of node.children ?? []) el.append(build(child));
65
+ return el;
66
+ }
67
+
68
+ /**
69
+ * Connect the authored root — these elements build on connect — run, and take it down again.
70
+ *
71
+ * The teardown is in a `finally` because a RED test must not leave a mounted tree behind:
72
+ * the next test would then be reading a document two blocks deep, and the failure it
73
+ * reported would name the wrong renderer.
74
+ */
75
+ function mounted<T>(node: SharedTreeNode, use: (root: Element) => T): T {
76
+ const host = document.createElement('div');
77
+ host.append(build(node));
78
+ document.body.append(host);
79
+ try {
80
+ return use(host.firstElementChild!);
81
+ } finally {
82
+ host.remove();
83
+ }
84
+ }
85
+
86
+ const bannerButton = (banner: Element) => banner.querySelector<HTMLButtonElement>('.adw-banner-button');
87
+
88
+ /**
89
+ * Read one observable off the element this renderer built.
90
+ *
91
+ * The ONE seam between the shared expectations and this renderer, and the mirror of
92
+ * `read()` in the gtk-host driver. Everything above it is renderer-free.
93
+ */
94
+ function read(expectation: SharedTreeExpectation, el: Element): string | number | boolean {
95
+ switch (expectation.observable) {
96
+ case 'entry-text-length':
97
+ return (el as unknown as { textLength: number }).textLength;
98
+ case 'switch-row-active':
99
+ return (el as unknown as { active: boolean }).active;
100
+ case 'banner-button-visible': {
101
+ const button = bannerButton(el);
102
+ return button ? !button.hidden : false;
103
+ }
104
+ case 'banner-button-text':
105
+ return bannerButton(el)?.textContent ?? '';
106
+ }
107
+ }
108
+
109
+ /**
110
+ * The realised tree, depth-first in document order and filtered to the authored element
111
+ * names: the parts an element renders for itself (a listbox, a revealer, a title span) are
112
+ * not the renderer's promise; the ORDER and the NESTING of what was authored is.
113
+ */
114
+ function realised(root: Element, wanted: readonly string[]): Element[] {
115
+ const set = new Set(wanted);
116
+ return [root, ...root.querySelectorAll('*')].filter((el) => set.has(el.tagName.toLowerCase()));
117
+ }
118
+
119
+ export const AdwSharedTreesTest = async () => {
120
+ await describe('the shared corpus builds through adwaita-web', async () => {
121
+ for (const block of ADWAITA_GALLERY_SHARED_TREES) {
122
+ await it(`${block.widget} builds, and the REAL DOM carries the authored nodes in order`, () => {
123
+ const wanted = authoredTags(block.root, hostTagOf);
124
+
125
+ mounted(block.root, (root) => {
126
+ const built = realised(root, wanted).map((el) => el.tagName.toLowerCase());
127
+ expect(built).toStrictEqual(wanted);
128
+ });
129
+ });
130
+ }
131
+ });
132
+
133
+ await describe('the shared corpus against the adwaita-core vectors it reaches', async () => {
134
+ for (const block of ADWAITA_GALLERY_SHARED_TREES) {
135
+ for (const expectation of sharedTreeExpectations(block.root)) {
136
+ await it(`${block.widget} — ${expectation.path}: ${expectation.table} — ${expectation.rule}`, () => {
137
+ mounted(block.root, (root) => {
138
+ // The SAME filtered walk the shape test asserts, so the element an
139
+ // expectation is read off is the one at the authored ADDRESS rather
140
+ // than the first of its name the DOM happens to contain.
141
+ const built = realised(root, authoredTags(block.root, hostTagOf));
142
+ const subject = built[subjectIndexOf(block.root, expectation.path)]!;
143
+ expect(subject.tagName.toLowerCase()).toBe(hostTagOf(expectation.gtype));
144
+
145
+ expect(read(expectation, subject)).toBe(expectation.expected);
146
+ });
147
+ });
148
+ }
149
+ }
150
+ });
151
+ };
@@ -207,7 +207,7 @@ export const AdwTabViewConformanceTest = async () => {
207
207
  });
208
208
  });
209
209
 
210
- await describe('adw-tab-view selected attribute', async () => {
210
+ await describe('adw-tab-view selected-page attribute', async () => {
211
211
  function mountThree(attrs = ''): { view: AdwTabView; host: HTMLElement } {
212
212
  const host = document.createElement('div');
213
213
  host.innerHTML = `<adw-tab-view ${attrs}>
@@ -219,38 +219,92 @@ export const AdwTabViewConformanceTest = async () => {
219
219
  return { view: host.querySelector('adw-tab-view') as AdwTabView, host };
220
220
  }
221
221
 
222
- await it('selects the declared index instead of the auto-pick', () => {
223
- const { view, host } = mountThree('selected="2"');
222
+ await it('selects the declared PAGE instead of the auto-pick', () => {
223
+ const { view, host } = mountThree('selected-page="c"');
224
224
  expect(view.selectedIndex).toBe(2);
225
+ expect(view.selectedId).toBe('c');
225
226
  expect(shownPanels(view)).toStrictEqual([false, false, true]);
226
227
  host.remove();
227
228
  });
228
229
 
229
- await it('IGNORES an out-of-range or unparseable index instead of clamping it', () => {
230
- // A clamping `Number.isNaN(raw) ? 0 : Math.min(Math.max(raw, 0), max)` selects
231
- // the last page for `99` and the first for `-1`/`oops`; libadwaita refuses all
232
- // three.
230
+ await it('IGNORES an id no page holds instead of falling back to the first', () => {
231
+ // The ordinal spellings are just unknown ids now (ADR 0048), and a fallback to
232
+ // page 0 would be the same wrong answer clamping used to give: libadwaita
233
+ // refuses a foreign `AdwTabPage` outright.
233
234
  const { view, host } = mountThree();
234
235
  for (const value of ['99', '-1', 'oops']) {
235
- view.setAttribute('selected', value);
236
+ view.setAttribute('selected-page', value);
236
237
  expect(view.selectedIndex).toBe(0);
237
238
  }
238
239
  //...and the attribute is put back in sync rather than left lying.
239
- view.setAttribute('selected', '1');
240
+ view.setAttribute('selected-page', 'b');
240
241
  expect(view.selectedIndex).toBe(1);
241
- expect(view.getAttribute('selected')).toBe('1');
242
+ expect(view.getAttribute('selected-page')).toBe(view.pages[1]!.id);
243
+ host.remove();
244
+ });
245
+
246
+ await it('REFUSES a page belonging to ANOTHER view, and RECORDS it', () => {
247
+ // `page_belongs_to_this_view`. An id is only unique WITHIN a view — declared ones
248
+ // are the author's to repeat and `_nextId` counts per element, so two views both
249
+ // hold a `tab-1`. Both views here hold 'a'/'b'/'c', which is the collision.
250
+ const first = mountThree();
251
+ const second = mountThree();
252
+ second.view.selectedPage = second.view.pages[2]!;
253
+ expect(second.view.selectedIndex).toBe(2);
254
+
255
+ first.view.selectedPage = second.view.pages[2]!;
256
+ expect(first.view.selectedIndex).toBe(0);
257
+ expect(second.view.selectedIndex).toBe(2);
258
+ // The refusal carries C's own assertion. It did NOT while the check was a
259
+ // renderer-side guard returning in silence, which is why the check moved into
260
+ // `TabViewState` — the one place that owns the page list.
261
+ expect(first.view.diagnostics).toStrictEqual([
262
+ "adw_tab_view_set_selected_page: assertion 'page_belongs_to_this_view (self, selected_page)' failed",
263
+ ]);
264
+ expect(second.view.diagnostics).toStrictEqual([]);
265
+
266
+ // ...and this view's OWN page of that same id is accepted, so what was refused
267
+ // is the IDENTITY and not the id.
268
+ first.view.selectedPage = first.view.pages[2]!;
269
+ expect(first.view.selectedIndex).toBe(2);
270
+ first.host.remove();
271
+ second.host.remove();
272
+ });
273
+
274
+ await it('RECORDS the refusal, where the ordinal door recorded nothing', () => {
275
+ // The behaviour change ADR 0048 does not name: `selected="99"` went through
276
+ // `selectNthPage`, which refuses an out-of-range index SILENTLY, while an
277
+ // unknown id goes through `setSelectedPage`, which is where C's
278
+ // `page_belongs_to_this_view` assertion lives. Three refusals, three records —
279
+ // if this list stays empty the element is swallowing the typo the reader made.
280
+ const { view, host } = mountThree();
281
+ expect(view.diagnostics).toStrictEqual([]);
282
+ for (const value of ['99', 'oops']) view.setAttribute('selected-page', value);
283
+ expect(view.diagnostics).toStrictEqual([
284
+ "adw_tab_view_set_selected_page: assertion 'page_belongs_to_this_view (self, selected_page)' failed",
285
+ "adw_tab_view_set_selected_page: assertion 'page_belongs_to_this_view (self, selected_page)' failed",
286
+ ]);
287
+
288
+ // ...and `null` while pages exist is the OTHER half of the same C assertion,
289
+ // which `view.selected = -1` also used to reach in silence.
290
+ view.selectedPage = null;
291
+ expect(view.selectedIndex).toBe(0);
292
+ expect(view.diagnostics).toHaveLength(3);
293
+ expect(view.diagnostics[2]).toBe(
294
+ "adw_tab_view_set_selected_page: assertion 'ADW_IS_TAB_PAGE (selected_page)' failed",
295
+ );
242
296
  host.remove();
243
297
  });
244
298
 
245
299
  await it('notifies on a PROGRAMMATIC selection, not only on a click', () => {
246
- // C notifies on EVERY path: a programmatic `view.selected = 2` must notify as
247
- // a tab click does.
300
+ // C notifies on EVERY path: a programmatic `view.selectedPage = …` must notify
301
+ // as a tab click does.
248
302
  const { view, host } = mountThree();
249
303
  const seen: number[] = [];
250
304
  view.addEventListener('notify::selected-page', (event) =>
251
305
  seen.push((event as CustomEvent).detail.selected),
252
306
  );
253
- view.selected = 2;
307
+ view.selectedPage = view.pages[2]!;
254
308
  expect(seen).toStrictEqual([2]);
255
309
  host.remove();
256
310
  });
@@ -259,11 +313,11 @@ export const AdwTabViewConformanceTest = async () => {
259
313
  const { view, host } = mountThree();
260
314
  const chips = tabChips(view);
261
315
  chips[1]!.click();
262
- expect(view.getAttribute('selected')).toBe('1');
316
+ expect(view.getAttribute('selected-page')).toBe(view.pages[1]!.id);
263
317
 
264
318
  for (const id of ['a', 'b', 'c']) view.closePage(id);
265
319
  expect(view.nPages).toBe(0);
266
- expect(view.hasAttribute('selected')).toBe(false);
320
+ expect(view.hasAttribute('selected-page')).toBe(false);
267
321
  expect(view.selectedIndex).toBe(-1);
268
322
  host.remove();
269
323
  });
@@ -53,8 +53,10 @@ import { AdwEmptySectionsTest } from './empty-sections.spec.js';
53
53
  import { AdwSlottedChildrenTest } from './slotted-children.spec.js';
54
54
  import { AdwKeyboardOperableTest } from './keyboard-operable.spec.js';
55
55
  import { AdwFontsTest } from './adw-fonts.spec.js';
56
+ import { AdwSharedTreesTest } from './shared-trees.spec.js';
56
57
 
57
58
  run({
59
+ AdwSharedTreesTest,
58
60
  AdwKeyboardOperableTest,
59
61
  AdwConnectLifecycleTest,
60
62
  AdwEmptySectionsTest,
@@ -66,7 +66,9 @@ function mountStack(pages: readonly ViewStackVectorPage[], visibleChildName?: st
66
66
  function applyOp(stack: AdwViewStack, op: ViewStackVectorOp): void {
67
67
  switch (op.kind) {
68
68
  case 'selectIndex':
69
- stack.visibleChildIndex = op.index;
69
+ // ADR 0048: the element has no ordinal SETTER any more; the ordinal is this
70
+ // call, and it forwards to the same core guard the vector pins.
71
+ stack.selectNthPage(op.index);
70
72
  break;
71
73
  case 'selectName':
72
74
  stack.visibleChildName = op.name;
@@ -224,7 +226,7 @@ export const AdwViewStackTest = async () => {
224
226
  await it('reflects the selection back, including an empty name', () => {
225
227
  const { stack, host } = mountStack([{ name: 'a' }, { name: '' }]);
226
228
  expect(stack.getAttribute('visible-child-name')).toBe('a');
227
- stack.visibleChildIndex = 1;
229
+ stack.selectNthPage(1);
228
230
  // Reflection guarded by `if (current && …)` never fires for nameless pages.
229
231
  expect(stack.getAttribute('visible-child-name')).toBe('');
230
232
  host.remove();
@@ -252,7 +254,7 @@ export const AdwViewStackTest = async () => {
252
254
  // with duplicate names a naive re-lookup resolves to the FIRST match and
253
255
  // undoes the selection.
254
256
  const { stack, host } = mountStack([{ name: 'dup' }, { name: 'dup' }]);
255
- stack.visibleChildIndex = 1;
257
+ stack.selectNthPage(1);
256
258
  expect(stack.visibleChildIndex).toBe(1);
257
259
  expect(stack.getAttribute('visible-child-name')).toBe('dup');
258
260
  host.remove();