@gjsify/adwaita-web 0.32.0 → 0.33.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 (38) hide show
  1. package/dist/adwaita-web.css +72 -50
  2. package/dist/adwaita-web.css.map +1 -1
  3. package/lib/types/elements/adw-combo-row.d.ts +10 -0
  4. package/lib/types/elements/adw-inline-view-switcher.d.ts +37 -1
  5. package/lib/types/elements/adw-navigation-split-view.d.ts +42 -0
  6. package/lib/types/elements/adw-overlay-split-view.d.ts +56 -0
  7. package/lib/types/elements/adw-spinner.d.ts +23 -0
  8. package/lib/types/elements/adw-view-stack.d.ts +12 -0
  9. package/lib/types/elements/adw-view-switcher-bar.d.ts +7 -5
  10. package/lib/types/elements/adw-view-switcher.d.ts +55 -3
  11. package/lib/types/elements/adw-wrap-box.d.ts +58 -3
  12. package/lib/types/styles.generated.d.ts +1 -1
  13. package/package.json +6 -6
  14. package/scss/_combo_row.scss +19 -0
  15. package/scss/_navigation_split_view.scss +47 -8
  16. package/scss/_overlay_split_view.scss +117 -32
  17. package/scss/_spinner.scss +35 -21
  18. package/scss/_tab_view.scss +12 -13
  19. package/scss/_wrap_box.scss +9 -0
  20. package/src/adw-avatar.spec.ts +37 -0
  21. package/src/adw-button-content.spec.ts +24 -0
  22. package/src/adw-row-state.spec.ts +49 -0
  23. package/src/adw-wrap-box.spec.ts +148 -1
  24. package/src/chrome.spec.ts +111 -12
  25. package/src/elements/adw-action-row.ts +11 -1
  26. package/src/elements/adw-combo-row.ts +17 -0
  27. package/src/elements/adw-inline-view-switcher.ts +91 -8
  28. package/src/elements/adw-navigation-split-view.ts +179 -14
  29. package/src/elements/adw-overlay-split-view.ts +378 -20
  30. package/src/elements/adw-spinner.ts +165 -21
  31. package/src/elements/adw-tab-view.ts +20 -2
  32. package/src/elements/adw-view-stack.ts +21 -0
  33. package/src/elements/adw-view-switcher-bar.ts +13 -5
  34. package/src/elements/adw-view-switcher.ts +103 -11
  35. package/src/elements/adw-wrap-box.ts +290 -135
  36. package/src/split-views.spec.ts +586 -2
  37. package/src/styles.generated.ts +1 -1
  38. package/src/view-switcher.spec.ts +219 -0
@@ -1,45 +1,189 @@
1
1
  // <adw-spinner> — A continuously-spinning loading indicator.
2
- // Attributes: size (px; the measured minimum, 16, when absent or unusable).
2
+ // Attributes: size (px; the BOX, floored at the measured minimum 16).
3
3
  //
4
- // The ring geometry is `Adw.Spinner`'s, not a guess: the diameter and the stroke
5
- // come from `@gjsify/adwaita-core`'s `spinnerGeometry`, which is
6
- // `AdwSpinnerPaintable`'s `radius = min(floor(min(w, h) / 2), 32)` with
7
- // `lineWidth = diameter / 8`. This element used to default to 24px and stroke
8
- // `max(2, round(size / 12))`, which draws 2px where Adwaita draws 3px at 24, 4px
9
- // against 6px at 48 and 5px against 8px at 64 — the ring was visibly thinner than
10
- // GTK's at every size above the minimum.
4
+ // THE BOX AND THE RING ARE DIFFERENT THINGS. `adw_spinner_measure`
5
+ // (adw-spinner.c:78-81) reports MIN_SIZE as both minimum and natural and has no
6
+ // upper bound — `MAX_SIZE` at :15 is defined and never referenced — while
7
+ // `adw_spinner_snapshot` (:95-99) hands the widget's real width and height to
8
+ // the paintable, which caps only `radius` (adw-spinner-paintable.c:343) and
9
+ // still centres on the box (:349-351). So `size="200"` occupies 200px of layout
10
+ // and draws a 64px ring in the middle. This element used to BE the ring, which
11
+ // is why its suite asserted the opposite of the core's `SPINNER_SIZE_VECTORS`.
11
12
  //
12
- // Reference: refs/libadwaita/src/adw-spinner.c (MIN_SIZE, adw_spinner_measure)
13
- // Reference: refs/libadwaita/src/adw-spinner-paintable.c (MAX_RADIUS, calculate_line_width)
14
- // Reference: refs/adwaita-web/adwaita-web/scss/_spinner.scss
13
+ // THE ARC IS NOT A ROTATING QUARTER-CIRCLE. It extends, overlaps, contracts and
14
+ // idles on an ease-in-out-sine while the figure turns; the arithmetic is
15
+ // `@gjsify/adwaita-core`'s `spinnerArc`, shared with the vectors that judge it.
16
+ // Expressing that needs a per-frame value, so this element draws an SVG arc and
17
+ // advances it from one module-level ticker — not one timer per spinner. CSS
18
+ // keyframes cannot express it: a `border-top-color` chase is a fixed 90 degrees,
19
+ // which is what this element drew for its whole life.
20
+ //
21
+ // IT KEEPS SPINNING UNDER REDUCED MOTION. `adw_spinner_paintable_set_widget`
22
+ // calls `adw_animation_set_follow_enable_animations_setting (…, FALSE)` (:537)
23
+ // — deliberately, because a frozen busy indicator reads as a hang. The old
24
+ // `@media (prefers-reduced-motion: reduce) { animation: none }` did exactly that.
25
+ //
26
+ // Reference: refs/libadwaita/src/adw-spinner.c (MIN_SIZE, adw_spinner_measure, the a11y role)
27
+ // Reference: refs/libadwaita/src/adw-spinner-paintable.c (the animation + geometry)
15
28
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
16
29
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
17
30
 
18
- import { resolveSpinnerSize, spinnerGeometry } from '@gjsify/adwaita-core';
31
+ import {
32
+ ADW_SPINNER_STILL_PROGRESS,
33
+ ADW_SPINNER_TRACK_OPACITY,
34
+ resolveSpinnerSize,
35
+ spinnerArc,
36
+ spinnerGeometry,
37
+ spinnerProgressAt,
38
+ } from '@gjsify/adwaita-core';
39
+
40
+ const SVG_NS = 'http://www.w3.org/2000/svg';
41
+
42
+ /**
43
+ * Every mapped spinner, driven by ONE `requestAnimationFrame` loop.
44
+ *
45
+ * The C animates per widget off the frame clock, which costs nothing extra
46
+ * because the clock is already ticking. A browser has no such clock, so the
47
+ * equivalent is one shared rAF: N spinners on a page must not mean N loops, and
48
+ * the loop must not exist at all while none are mapped.
49
+ */
50
+ const mapped = new Set<AdwSpinner>();
51
+ let frameHandle = 0;
52
+
53
+ function tick(now: number): void {
54
+ for (const spinner of mapped) spinner.drawAt(now);
55
+ frameHandle = mapped.size > 0 ? requestAnimationFrame(tick) : 0;
56
+ }
57
+
58
+ function startTicking(): void {
59
+ if (frameHandle === 0 && mapped.size > 0) frameHandle = requestAnimationFrame(tick);
60
+ }
19
61
 
20
62
  export class AdwSpinner extends HTMLElement {
21
63
  static get observedAttributes() {
22
64
  return ['size'];
23
65
  }
24
66
 
67
+ private _svg: SVGSVGElement | null = null;
68
+ private _track: SVGCircleElement | null = null;
69
+ private _arc: SVGCircleElement | null = null;
70
+ /** Path length of the ring, so the dash arithmetic is done once per resize. */
71
+ private _circumference = 0;
72
+ /** Ring radius in px — the dash lengths are arc-length, i.e. radians × r. */
73
+ private _radius = 0;
74
+ /** The timestamp this spinner was mapped at, so its progress starts at 0. */
75
+ private _origin: number | null = null;
76
+
25
77
  connectedCallback() {
26
- this._sync();
78
+ this._build();
79
+ // `widget_map_cb` (adw-spinner-paintable.c:181-185) plays the animation
80
+ // on MAP, not on construction: an off-screen spinner burns nothing.
81
+ mapped.add(this);
82
+ this._origin = null;
83
+ startTicking();
84
+ }
85
+
86
+ disconnectedCallback() {
87
+ mapped.delete(this);
27
88
  }
28
89
 
29
90
  attributeChangedCallback() {
30
91
  this._sync();
31
92
  }
32
93
 
33
- private _sync() {
34
- // The element IS the ring, so it takes the DRAWN diameter rather than the
35
- // requested box: a request below 16 is not representable (GTK never
36
- // allocates below a widget's minimum) and one above 64 draws a 64px ring,
37
- // which is Adwaita's documented "never larger than 64×64".
94
+ /**
95
+ * Draw the frame for `now`, in `requestAnimationFrame` timebase.
96
+ *
97
+ * Public because the shared ticker is a module-level function rather than a
98
+ * method — and because a test needs to place the spinner at a known moment
99
+ * without waiting for real frames.
100
+ */
101
+ drawAt(now: number): void {
102
+ if (this._origin === null) this._origin = now;
103
+ this._paint(spinnerProgressAt(now - this._origin));
104
+ }
105
+
106
+ private _build(): void {
107
+ if (this._svg) return;
108
+ const svg = document.createElementNS(SVG_NS, 'svg');
109
+ svg.setAttribute('fill', 'none');
110
+ // The ring is decorative; the ROLE is on the host, per adw-spinner.c:124.
111
+ svg.setAttribute('aria-hidden', 'true');
112
+
113
+ const track = document.createElementNS(SVG_NS, 'circle');
114
+ track.setAttribute('stroke', 'currentColor');
115
+ // `CIRCLE_OPACITY` of the WIDGET's colour (adw-spinner-paintable.c:367-369),
116
+ // not a hardcoded grey — so the track follows the text colour everywhere.
117
+ track.setAttribute('stroke-opacity', String(ADW_SPINNER_TRACK_OPACITY));
118
+
119
+ const arc = document.createElementNS(SVG_NS, 'circle');
120
+ arc.setAttribute('stroke', 'currentColor');
121
+ // `GSK_LINE_CAP_ROUND` (adw-spinner-paintable.c:354). The port's
122
+ // `border-top-color` ends were square-cut, visible from 48px up.
123
+ arc.setAttribute('stroke-linecap', 'round');
124
+
125
+ svg.append(track, arc);
126
+ this._svg = svg;
127
+ this._track = track;
128
+ this._arc = arc;
129
+ this.replaceChildren(svg);
130
+
131
+ // `gtk_widget_class_set_accessible_role (…, PROGRESS_BAR)` (adw-spinner.c:124)
132
+ // and `GTK_ACCESSIBLE_STATE_BUSY, TRUE` (:139-141). A repo-wide grep for
133
+ // either found ZERO hits before this: screen readers announced nothing.
134
+ this.setAttribute('role', 'progressbar');
135
+ this.setAttribute('aria-busy', 'true');
136
+
137
+ this._sync();
138
+ }
139
+
140
+ /** Re-derive the box, the ring and the stroke from the `size` attribute. */
141
+ private _sync(): void {
142
+ const svg = this._svg;
143
+ const track = this._track;
144
+ const arc = this._arc;
145
+ if (!svg || !track || !arc) return;
146
+
38
147
  const size = resolveSpinnerSize(this.getAttribute('size'));
39
148
  const { diameter, lineWidth } = spinnerGeometry(size, size);
40
- this.style.width = `${diameter}px`;
41
- this.style.height = `${diameter}px`;
42
- this.style.borderWidth = `${lineWidth}px`;
149
+ // THE BOX: whatever was asked for, floored at the measured minimum.
150
+ this.style.width = `${size}px`;
151
+ this.style.height = `${size}px`;
152
+ // THE RING: capped at 64, centred by the host's flex box.
153
+ svg.setAttribute('width', String(diameter));
154
+ svg.setAttribute('height', String(diameter));
155
+ svg.setAttribute('viewBox', `0 0 ${diameter} ${diameter}`);
156
+
157
+ // The stroke is centred on the path, so the circle sits half a stroke in
158
+ // — `radius - line_width / 2` (adw-spinner-paintable.c:364).
159
+ const radius = (diameter - lineWidth) / 2;
160
+ this._radius = radius;
161
+ this._circumference = 2 * Math.PI * radius;
162
+ for (const circle of [track, arc]) {
163
+ circle.setAttribute('cx', String(diameter / 2));
164
+ circle.setAttribute('cy', String(diameter / 2));
165
+ circle.setAttribute('r', String(radius));
166
+ circle.setAttribute('stroke-width', String(lineWidth));
167
+ }
168
+ // A resize while unmapped still has to leave a drawable frame behind:
169
+ // the resting pose, which is what a paintable without an animation draws.
170
+ this._paint(this._origin === null ? ADW_SPINNER_STILL_PROGRESS : this._lastProgress);
171
+ }
172
+
173
+ /** The progress the last painted frame used, so a resize can repaint it. */
174
+ private _lastProgress = ADW_SPINNER_STILL_PROGRESS;
175
+
176
+ private _paint(progress: number): void {
177
+ const arc = this._arc;
178
+ if (!arc || this._radius <= 0) return;
179
+ this._lastProgress = progress;
180
+ const { end, length } = spinnerArc(progress);
181
+ const drawn = length * this._radius;
182
+ arc.setAttribute('stroke-dasharray', `${drawn} ${Math.max(this._circumference - drawn, 0)}`);
183
+ // An SVG circle's path starts at 3 o'clock and runs clockwise, which is
184
+ // the same origin and direction the C's angles use — so a radian is a
185
+ // path distance and the dash only has to be shifted to `end`.
186
+ arc.setAttribute('stroke-dashoffset', String(-end * this._radius));
43
187
  }
44
188
  }
45
189
 
@@ -513,6 +513,19 @@ export class AdwTabView extends HTMLElement {
513
513
  const icon = createAdwIcon(null, 'adw-tab-icon');
514
514
  tab.appendChild(icon);
515
515
 
516
+ // `AdwTabPage:loading` swaps the icon image for an `AdwSpinnerPaintable`
517
+ // — the SAME paintable `Adw.Spinner` uses (`update_icons`,
518
+ // adw-tab.c:172-190). So this is a real `<adw-spinner>` in the icon's
519
+ // slot, not a ring drawn again in CSS: the stylesheet used to restate
520
+ // the border, the grey and the 0.8s under a comment claiming it was
521
+ // "not a second copy of them", and it inherited every spinner defect
522
+ // independently.
523
+ const spinner = document.createElement('adw-spinner');
524
+ spinner.className = 'adw-tab-spinner';
525
+ spinner.setAttribute('size', '16');
526
+ spinner.hidden = true;
527
+ tab.appendChild(spinner);
528
+
516
529
  const label = document.createElement('span');
517
530
  label.className = 'adw-tab-title';
518
531
  tab.appendChild(label);
@@ -588,9 +601,14 @@ export class AdwTabView extends HTMLElement {
588
601
  const icon = tab.querySelector('.adw-tab-icon') as AdwIcon | null;
589
602
  if (icon) {
590
603
  icon.iconName = icons.icon;
591
- icon.classList.toggle('loading', icons.spinner);
592
- icon.hidden = !icons.iconVisible;
604
+ // The two occupy the same slot and are never both visible: C
605
+ // REPLACES the image's contents rather than stacking a second node.
606
+ icon.hidden = !icons.iconVisible || icons.spinner;
593
607
  }
608
+ const spinner = tab.querySelector('.adw-tab-spinner') as HTMLElement | null;
609
+ // The spinner is mounted only while it spins, so an idle tab holds no
610
+ // element in the shared rAF ticker.
611
+ if (spinner) spinner.hidden = !(icons.spinner && icons.iconVisible);
594
612
  const indicator = tab.querySelector('.adw-tab-indicator') as AdwIcon | null;
595
613
  if (indicator) {
596
614
  indicator.iconName = page.indicatorIcon;
@@ -168,6 +168,7 @@ export class AdwViewStack extends HTMLElement {
168
168
  this.appendChild(content);
169
169
  this._applyVisibility();
170
170
  this._reflectName();
171
+ this._emitItemsChanged();
171
172
  return page;
172
173
  }
173
174
 
@@ -188,6 +189,7 @@ export class AdwViewStack extends HTMLElement {
188
189
  content?.remove();
189
190
  this._applyVisibility();
190
191
  this._reflectName();
192
+ this._emitItemsChanged();
191
193
  return true;
192
194
  }
193
195
 
@@ -200,9 +202,28 @@ export class AdwViewStack extends HTMLElement {
200
202
  setPageVisible(name: string, visible: boolean): boolean {
201
203
  const moved = this._state.setPageVisible(name, visible);
202
204
  this._applyVisibility();
205
+ // A visibility flip changes what the pages MODEL reports, which is what
206
+ // `update_bar_revealed` counts (adw-view-switcher-bar.c:340) — hiding
207
+ // the second-to-last visible page must retract the bar.
208
+ this._emitItemsChanged();
203
209
  return moved;
204
210
  }
205
211
 
212
+ /**
213
+ * The pages model's `items-changed`, which the DOM stack had no counterpart
214
+ * for at all.
215
+ *
216
+ * `AdwViewSwitcherBar` re-runs `update_bar_revealed` on it
217
+ * (adw-view-switcher-bar.c:340) and `AdwViewSwitcher` rebinds its buttons
218
+ * (adw-view-switcher.c:258-263). Both ports listened only on
219
+ * `notify::visible-child`, so adding a page or hiding a non-selected one
220
+ * left `revealed` — and the button row — stale, with a manual `refresh()`
221
+ * as the documented workaround.
222
+ */
223
+ private _emitItemsChanged(): void {
224
+ this.dispatchEvent(new CustomEvent('items-changed', { bubbles: true, detail: { count: this.pages.length } }));
225
+ }
226
+
206
227
  private _onStateChange(change: ViewStackStateChange<HTMLElement>): void {
207
228
  this._applyVisibility();
208
229
  this._reflectName();
@@ -147,11 +147,13 @@ export class AdwViewSwitcherBar extends HTMLElement {
147
147
  }
148
148
 
149
149
  /**
150
- * Rebuild the buttons — needed after pages were added to the bound stack
151
- * WITHOUT changing its selection. A page add that does move the selection
152
- * (the first one) notifies and rebuilds on its own. libadwaita rebuilds on
153
- * the pages model's `items-changed` (adw-view-switcher.c:258-263), which the
154
- * DOM stack has no counterpart for yet.
150
+ * Rebuild the buttons by hand.
151
+ *
152
+ * Kept as public API — a consumer that mutates a page in place still has a
153
+ * way to say so — but no longer the ONLY way: the stack emits
154
+ * `items-changed` now and this bar binds it, which is the mechanism
155
+ * libadwaita uses (adw-view-switcher-bar.c:340,
156
+ * adw-view-switcher.c:258-263).
155
157
  */
156
158
  refresh(): void {
157
159
  this._rebuild();
@@ -163,10 +165,16 @@ export class AdwViewSwitcherBar extends HTMLElement {
163
165
 
164
166
  private _bindStack(): void {
165
167
  this._stack?.addEventListener('notify::visible-child', this._onStackNotify);
168
+ // `update_bar_revealed` runs on the pages model's `items-changed` too
169
+ // (adw-view-switcher-bar.c:340), which is the half that was missing: a
170
+ // page added without moving the selection, or a non-selected page
171
+ // hidden, changes the COUNT the reveal is decided from.
172
+ this._stack?.addEventListener('items-changed', this._onStackNotify);
166
173
  }
167
174
 
168
175
  private _unbindStack(): void {
169
176
  this._stack?.removeEventListener('notify::visible-child', this._onStackNotify);
177
+ this._stack?.removeEventListener('items-changed', this._onStackNotify);
170
178
  }
171
179
 
172
180
  private _resolveStack(value: AdwViewStack | string | null): AdwViewStack | null {
@@ -56,6 +56,7 @@ import {
56
56
  buildViewSwitcherButtons,
57
57
  isViewSwitcherPolicy,
58
58
  } from '@gjsify/adwaita-core';
59
+ import type { ViewSwitcherScheduler } from '@gjsify/adwaita-core';
59
60
  // Aliased because `AdwViewSwitcherPage` is already this module's page ELEMENT —
60
61
  // the custom element the package has always exported under that name.
61
62
  import type {
@@ -82,18 +83,50 @@ interface PageNodes {
82
83
  body: HTMLDivElement;
83
84
  }
84
85
 
85
- /** A single page. Children of <adw-view-switcher>; consumed at connect time. */
86
- export class AdwViewSwitcherPage extends HTMLElement {
87
- static get observedAttributes() {
88
- return ['name', 'title', 'icon-name', 'badge-number', 'needs-attention', 'use-underline'];
89
- }
90
- }
86
+ /**
87
+ * A single page. Children of `<adw-view-switcher>`; consumed at connect time.
88
+ *
89
+ * Deliberately carries NO `observedAttributes`. It used to declare six of them
90
+ * and no `attributeChangedCallback`, which is a dead declaration — the browser
91
+ * computes the list and has nowhere to deliver to — so changing `badge-number`,
92
+ * `title`, `icon-name` or `needs-attention` after connect did nothing at all,
93
+ * where C rebinds on every page `notify` (adw-view-switcher.c:184-193, :223).
94
+ *
95
+ * A callback could not fix it either: the switcher consumes these elements at
96
+ * connect and `replaceChildren`s them away, so a detached page has no `closest`
97
+ * to reach its owner through. The switcher watches them with a `MutationObserver`
98
+ * instead, which keeps working after the detach and needs nothing from this class.
99
+ */
100
+ export class AdwViewSwitcherPage extends HTMLElement {}
91
101
 
92
102
  export class AdwViewSwitcher extends HTMLElement {
93
- private readonly _state = new ViewSwitcherState({ scheduler: domViewSwitcherScheduler });
103
+ /**
104
+ * The dwell timer, injectable.
105
+ *
106
+ * It used to be `new ViewSwitcherState({ scheduler: domViewSwitcherScheduler })`
107
+ * inline, which left NOWHERE to hand a clock in — so
108
+ * `VIEW_SWITCHER_DRAG_VECTORS` was driven by the core suite alone, and the
109
+ * conformance header claimed all three suites drove it. `scheduler` is a
110
+ * property so a test can replace it before the element connects; the DOM one
111
+ * is the default, exactly as before.
112
+ */
113
+ scheduler: ViewSwitcherScheduler = domViewSwitcherScheduler;
114
+ private _stateInstance: ViewSwitcherState | null = null;
115
+
116
+ /** Built on first access, so a `scheduler` set before connect is the one used. */
117
+ private get _state(): ViewSwitcherState {
118
+ if (!this._stateInstance) {
119
+ this._stateInstance = new ViewSwitcherState({ scheduler: this.scheduler });
120
+ this._stateInstance.subscribe((change) => this._onStateChange(change));
121
+ }
122
+ return this._stateInstance;
123
+ }
94
124
  private _barEl!: HTMLDivElement;
95
125
  private _contentEl!: HTMLDivElement;
96
126
  private _nodes: PageNodes[] = [];
127
+ /** The page ELEMENTS, kept because the switcher detaches them from the tree. */
128
+ private _declared: HTMLElement[] = [];
129
+ private _pageObserver: MutationObserver | undefined;
97
130
  private _policy: AdwViewSwitcherPolicy = 'narrow';
98
131
  private _initialized = false;
99
132
 
@@ -105,7 +138,6 @@ export class AdwViewSwitcher extends HTMLElement {
105
138
  super();
106
139
  // Subscribing touches no attributes and no children, so it is legal in a
107
140
  // custom element constructor — unlike anything DOM-shaped.
108
- this._state.subscribe((change) => this._onStateChange(change));
109
141
  }
110
142
 
111
143
  /** The policy controlling the button layout ('wide' | 'narrow'). */
@@ -153,7 +185,15 @@ export class AdwViewSwitcher extends HTMLElement {
153
185
  }
154
186
 
155
187
  connectedCallback() {
156
- if (this._initialized) return;
188
+ if (this._initialized) {
189
+ // Re-entering a document. `disconnectedCallback` drops the page
190
+ // observer on the way out, and this early return used to skip
191
+ // rebuilding it — so a switcher that had merely been MOVED (a
192
+ // slideshow slide, a client-side route change) stopped noticing
193
+ // page attribute changes for good, and did it silently.
194
+ this._watchPages();
195
+ return;
196
+ }
157
197
 
158
198
  // `:scope >` rather than a subtree scan: an unscoped query let an outer
159
199
  // switcher adopt the pages of a nested one, since both use the same tag.
@@ -172,6 +212,8 @@ export class AdwViewSwitcher extends HTMLElement {
172
212
  this._contentEl = document.createElement('div');
173
213
  this._contentEl.className = 'adw-view-switcher-content';
174
214
 
215
+ this._declared = declared;
216
+ this._watchPages();
175
217
  this._nodes = declared.map((pageEl, index) => this._buildPage(pageEl, index));
176
218
  this._barEl.append(...this._nodes.map((nodes) => nodes.button));
177
219
  this._contentEl.append(...this._nodes.map((nodes) => nodes.body));
@@ -193,6 +235,41 @@ export class AdwViewSwitcher extends HTMLElement {
193
235
  // A drag that leaves with the element would otherwise switch pages after
194
236
  // the widget is gone; C drops the source in dispose the same way.
195
237
  this._state.cancelDrag();
238
+ this._pageObserver?.disconnect();
239
+ this._pageObserver = undefined;
240
+ }
241
+
242
+ /**
243
+ * Re-read every declared page and push it into the state.
244
+ *
245
+ * Called by `AdwViewSwitcherPage.attributeChangedCallback`, which is what
246
+ * makes a runtime `badge-number` change reach the button. `setPages` keeps
247
+ * the selection where it can, so this is a repaint and not a reset.
248
+ */
249
+ refreshPages(): void {
250
+ if (!this._initialized) return;
251
+ this._state.setPages(this._declared.map((pageEl) => readSwitcherPage(pageEl)));
252
+ this._render();
253
+ }
254
+
255
+ /**
256
+ * Watch the declared pages for attribute changes.
257
+ *
258
+ * A `MutationObserver` rather than an `attributeChangedCallback` on the page:
259
+ * the switcher consumes those elements at connect and `replaceChildren`s them
260
+ * out of the tree, so a detached page cannot find its owner — but an observer
261
+ * bound to it keeps firing. This is the DOM's `notify::` on
262
+ * `AdwViewStackPage`, which the C's `update_button` handler binds per page
263
+ * (adw-view-switcher.c:184-193).
264
+ *
265
+ * It delivers on a MICROTASK where GTK's notify is synchronous. That is the
266
+ * DOM's contract for watching a node, it is invisible to a user, and nothing
267
+ * in this element reads the button back inside the setter.
268
+ */
269
+ private _watchPages(): void {
270
+ this._pageObserver?.disconnect();
271
+ this._pageObserver = new MutationObserver(() => this.refreshPages());
272
+ for (const pageEl of this._declared) this._pageObserver.observe(pageEl, { attributes: true });
196
273
  }
197
274
 
198
275
  attributeChangedCallback(name: string) {
@@ -239,8 +316,23 @@ export class AdwViewSwitcher extends HTMLElement {
239
316
  this._state.setSelected(index);
240
317
  });
241
318
  // GtkDropControllerMotion's enter/leave, which drive TIMEOUT_EXPAND.
242
- button.addEventListener('dragenter', () => this._state.dragEnter(index));
243
- button.addEventListener('dragleave', () => this._state.dragLeave(index));
319
+ //
320
+ // The `relatedTarget` test is the whole point: this button contains an
321
+ // icon and a label, and the DOM fires `dragleave` + `dragenter` when the
322
+ // pointer crosses BETWEEN them. `ViewSwitcherDragSwitch.leave` clears the
323
+ // dwell timer and `enter` re-arms a fresh 500ms, so a drag that merely
324
+ // slid from the icon to the label restarted the countdown and the page
325
+ // never switched. C has one `GtkDropControllerMotion` per BUTTON
326
+ // (adw-view-switcher-button.c:79-96), which has no such internal edges —
327
+ // `relatedTarget` containment is that same "did we really leave" test.
328
+ button.addEventListener('dragenter', (event) => {
329
+ if (button.contains((event as DragEvent).relatedTarget as Node | null)) return;
330
+ this._state.dragEnter(index);
331
+ });
332
+ button.addEventListener('dragleave', (event) => {
333
+ if (button.contains((event as DragEvent).relatedTarget as Node | null)) return;
334
+ this._state.dragLeave(index);
335
+ });
244
336
 
245
337
  const body = document.createElement('div');
246
338
  body.className = 'adw-view-switcher-page';