@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
@@ -23,7 +23,12 @@
23
23
  import { describe, expect, it } from '@gjsify/unit';
24
24
 
25
25
  import {
26
+ WRAP_BOX_CHILD_ORDER_VECTORS,
27
+ WRAP_BOX_LENGTH_VECTORS,
26
28
  WRAP_BOX_LINE_VECTORS,
29
+ WRAP_BOX_NATURAL_LENGTH_VECTORS,
30
+ WRAP_BOX_NOTIFY_PROPERTIES,
31
+ WRAP_BOX_POLICY_VECTORS,
27
32
  WRAP_BOX_SPACING_NOTIFY_VECTORS,
28
33
  WRAP_BOX_SPACING_VECTORS,
29
34
  } from '@gjsify/adwaita-core/conformance';
@@ -49,7 +54,7 @@ function unmountAll(): void {
49
54
  }
50
55
 
51
56
  /** A vector value as it reads in a test name (`JSON.stringify` flattens NaN to null). */
52
- function label(value: number | string | null): string {
57
+ function label(value: unknown): string {
53
58
  return typeof value === 'string' ? JSON.stringify(value) : String(value);
54
59
  }
55
60
 
@@ -191,4 +196,146 @@ export const AdwWrapBoxTest = async () => {
191
196
  });
192
197
  }
193
198
  });
199
+
200
+ await describe('<adw-wrap-box> the properties neither port had (#1048)', async () => {
201
+ for (const { value, policy, flexShrink, rule } of WRAP_BOX_POLICY_VECTORS) {
202
+ await it(`wrap-policy ${label(value)} → ${policy}: ${rule}`, () => {
203
+ const attrs: Record<string, string> = {};
204
+ if (value !== null) attrs['wrap-policy'] = String(value);
205
+ const { box, children } = mountWrapBox(attrs);
206
+ expect((box as HTMLElement & { wrapPolicy: string }).wrapPolicy).toBe(policy);
207
+ expect(getComputedStyle(children[0]).flexShrink).toBe(String(flexShrink));
208
+ unmountAll();
209
+ });
210
+ }
211
+
212
+ await it('a box with no wrap-policy forbids shrinking — CSS defaults to the OTHER one', () => {
213
+ const { children } = mountWrapBox({});
214
+ expect(getComputedStyle(children[0]).flexShrink).toBe('0');
215
+ unmountAll();
216
+ });
217
+
218
+ for (const { value, unit, dpi, px, rule } of WRAP_BOX_LENGTH_VECTORS) {
219
+ // The element resolves at the default dpi; the scaled rows are the
220
+ // core suite's, since a browser has no `gtk-xft-dpi` to report.
221
+ if (dpi !== 96 || value < 0) continue;
222
+ await it(`child-spacing ${value}${String(unit)} → ${px}px: ${rule}`, () => {
223
+ const attrs: Record<string, string> = { 'child-spacing': String(value) };
224
+ if (unit !== null) attrs['child-spacing-unit'] = String(unit);
225
+ const { box } = mountWrapBox(attrs);
226
+ expect(getComputedStyle(box).columnGap).toBe(`${px}px`);
227
+ unmountAll();
228
+ });
229
+ }
230
+
231
+ for (const { value, length, rule } of WRAP_BOX_NATURAL_LENGTH_VECTORS) {
232
+ await it(`natural-line-length ${label(value)} → ${length}: ${rule}`, () => {
233
+ const attrs: Record<string, string> = {};
234
+ if (value !== null) attrs['natural-line-length'] = String(value);
235
+ const { box } = mountWrapBox(attrs);
236
+ expect((box as HTMLElement & { naturalLineLength: number }).naturalLineLength).toBe(length);
237
+ // -1 is UNSET, not a length: it must not reach the style at all.
238
+ expect(box.style.maxWidth).toBe(length < 0 ? '' : `${length}px`);
239
+ unmountAll();
240
+ });
241
+ }
242
+
243
+ await it('a VERTICAL box caps the block axis, because that is where its lines run', () => {
244
+ const { box } = mountWrapBox({ orientation: 'vertical', 'natural-line-length': '400' });
245
+ expect(box.style.maxHeight).toBe('400px');
246
+ expect(box.style.maxWidth).toBe('');
247
+ unmountAll();
248
+ });
249
+
250
+ await it('pack-direction and wrap-reverse reverse the axes they own', () => {
251
+ const packed = mountWrapBox({ 'pack-direction': 'end-to-start' });
252
+ expect(getComputedStyle(packed.box).flexDirection).toBe('row-reverse');
253
+ unmountAll();
254
+
255
+ const reversed = mountWrapBox({ 'wrap-reverse': '' });
256
+ expect(getComputedStyle(reversed.box).flexWrap).toBe('wrap-reverse');
257
+ unmountAll();
258
+ });
259
+
260
+ await it('notifies for EVERY property, not just the two spacings', () => {
261
+ const { box } = mountWrapBox({});
262
+ const seen: string[] = [];
263
+ for (const property of WRAP_BOX_NOTIFY_PROPERTIES) {
264
+ box.addEventListener(`notify::${property}`, () => seen.push(property));
265
+ }
266
+ // One real change per property, each different from the default.
267
+ box.setAttribute('child-spacing', '6');
268
+ box.setAttribute('child-spacing-unit', 'sp');
269
+ box.setAttribute('pack-direction', 'end-to-start');
270
+ box.setAttribute('align', '1');
271
+ box.setAttribute('justify', 'fill');
272
+ box.toggleAttribute('justify-last-line', true);
273
+ box.setAttribute('line-spacing', '4');
274
+ box.setAttribute('line-spacing-unit', 'pt');
275
+ box.toggleAttribute('line-homogeneous', true);
276
+ box.setAttribute('natural-line-length', '400');
277
+ box.setAttribute('natural-line-length-unit', 'sp');
278
+ box.toggleAttribute('wrap-reverse', true);
279
+ box.setAttribute('wrap-policy', 'minimum');
280
+ box.setAttribute('orientation', 'vertical');
281
+
282
+ expect([...seen].sort()).toStrictEqual([...WRAP_BOX_NOTIFY_PROPERTIES].sort());
283
+ unmountAll();
284
+ });
285
+
286
+ await it('and stays silent for a value that normalises to what it already holds', () => {
287
+ const { box } = mountWrapBox({ 'wrap-policy': 'minimum' });
288
+ let notified = 0;
289
+ box.addEventListener('notify::wrap-policy', () => {
290
+ notified++;
291
+ });
292
+ box.setAttribute('wrap-policy', 'not-a-policy'); // → the default, a change
293
+ expect(notified).toBe(1);
294
+ box.setAttribute('wrap-policy', 'also-not-a-policy'); // → the default again
295
+ expect(notified).toBe(1);
296
+ unmountAll();
297
+ });
298
+ });
299
+
300
+ await describe('<adw-wrap-box> child list (insert/reorder after, remove_all)', async () => {
301
+ for (const { op, children, child, sibling, result, rule } of WRAP_BOX_CHILD_ORDER_VECTORS) {
302
+ const name = `${op} ${child} after ${sibling ?? 'NULL'} in [${children.join(',')}]`;
303
+ await it(`${name}: ${rule}`, () => {
304
+ const box = document.createElement('adw-wrap-box') as HTMLElement & {
305
+ insertChildAfter(c: Element, s?: Element | null): boolean;
306
+ reorderChildAfter(c: Element, s?: Element | null): boolean;
307
+ };
308
+ document.body.appendChild(box);
309
+ const byId = new Map<string, HTMLElement>();
310
+ const make = (id: string) => {
311
+ const view = document.createElement('div');
312
+ view.dataset.id = id;
313
+ byId.set(id, view);
314
+ return view;
315
+ };
316
+ for (const id of children) box.appendChild(make(id));
317
+ // The child being placed may be one of the mounted ones (a
318
+ // reorder, or the "already parented" refusal) or a fresh one.
319
+ const target = byId.get(child) ?? make(child);
320
+ // A sibling the box does not have still has to be a real element.
321
+ const after = sibling === null ? null : (byId.get(sibling) ?? make(sibling));
322
+
323
+ const ok =
324
+ op === 'insert-after' ? box.insertChildAfter(target, after) : box.reorderChildAfter(target, after);
325
+ expect(ok).toBe(result !== null);
326
+ const order = [...box.children].map((view) => (view as HTMLElement).dataset.id);
327
+ expect(order).toStrictEqual(result === null ? [...children] : [...result]);
328
+ unmountAll();
329
+ });
330
+ }
331
+
332
+ await it('removeAll empties the box, leaving the box itself mounted', () => {
333
+ const { box } = mountWrapBox({}, 4);
334
+ expect(box.children.length).toBe(4);
335
+ (box as HTMLElement & { removeAll(): void }).removeAll();
336
+ expect(box.children.length).toBe(0);
337
+ expect(box.isConnected).toBe(true);
338
+ unmountAll();
339
+ });
340
+ });
194
341
  };
@@ -14,7 +14,7 @@
14
14
  // faking one.
15
15
  import { describe, expect, it } from '@gjsify/unit';
16
16
 
17
- import { ADW_SPINNER_MIN_SIZE, spinnerGeometry } from '@gjsify/adwaita-core';
17
+ import { ADW_SPINNER_MIN_SIZE, ADW_SPINNER_TRACK_OPACITY, spinnerGeometry } from '@gjsify/adwaita-core';
18
18
  import {
19
19
  CLAMP_ALLOCATE_VECTORS,
20
20
  CLAMP_PROPERTY_VECTORS,
@@ -201,52 +201,86 @@ export const AdwChromeTest = async () => {
201
201
  });
202
202
 
203
203
  await describe('adw-spinner geometry (AdwSpinnerPaintable conformance vectors)', async () => {
204
+ /** The `<svg>` the element draws its ring into. */
205
+ const ringOf = (spinner: HTMLElement) => spinner.querySelector('svg') as SVGSVGElement;
206
+
204
207
  for (const { width, height, diameter, lineWidth, rule } of SPINNER_GEOMETRY_VECTORS) {
205
- // The element is the ring itself, so only the square, allocatable
206
- // boxes describe what a `size` attribute can produce.
208
+ // Only square, allocatable boxes describe what a `size` attribute
209
+ // can produce; the rectangular rows exercise the paintable directly.
207
210
  if (width !== height || width < ADW_SPINNER_MIN_SIZE) continue;
208
211
 
209
212
  await it(`size ${width} draws a ${diameter}px ring stroked ${lineWidth}px — ${rule}`, () => {
210
- const { host } = mountSized(200);
213
+ const { host } = mountSized(400);
211
214
  const spinner = document.createElement('adw-spinner');
212
215
  spinner.setAttribute('size', String(width));
213
216
  host.appendChild(spinner);
214
217
 
215
- expect(spinner.offsetWidth).toBe(diameter);
216
- expect(spinner.offsetHeight).toBe(diameter);
217
- expect(Number.parseFloat(spinner.style.borderWidth)).toBe(lineWidth);
218
+ const svg = ringOf(spinner);
219
+ expect(svg.getAttribute('width')).toBe(String(diameter));
220
+ expect(svg.getAttribute('height')).toBe(String(diameter));
221
+ const arc = svg.querySelectorAll('circle')[1] as SVGCircleElement;
222
+ expect(Number(arc.getAttribute('stroke-width'))).toBe(lineWidth);
218
223
  host.remove();
219
224
  });
220
225
  }
221
226
 
222
227
  await it('strokes 3px at 24 and 6px at 48, where it used to stroke 2 and 4', () => {
223
- const { host } = mountSized(200);
228
+ const { host } = mountSized(400);
224
229
  const spinner = document.createElement('adw-spinner');
225
230
  host.appendChild(spinner);
226
231
  const strokeAt = (size: number) => {
227
232
  spinner.setAttribute('size', String(size));
228
- return Number.parseFloat(spinner.style.borderWidth);
233
+ return Number(ringOf(spinner).querySelector('circle')?.getAttribute('stroke-width'));
229
234
  };
230
235
  expect(strokeAt(24)).toBe(3);
231
236
  expect(strokeAt(48)).toBe(6);
232
237
  expect(strokeAt(64)).toBe(8);
233
238
  host.remove();
234
239
  });
240
+
241
+ await it('rounds its arc ends — GSK_LINE_CAP_ROUND, square-cut before', () => {
242
+ const { host } = mountSized(400);
243
+ const spinner = document.createElement('adw-spinner');
244
+ spinner.setAttribute('size', '64');
245
+ host.appendChild(spinner);
246
+ const [track, arc] = [...ringOf(spinner).querySelectorAll('circle')];
247
+ expect(arc.getAttribute('stroke-linecap')).toBe('round');
248
+ // The TRACK is the widget colour at CIRCLE_OPACITY, not a grey.
249
+ expect(track.getAttribute('stroke')).toBe('currentColor');
250
+ expect(Number(track.getAttribute('stroke-opacity'))).toBe(ADW_SPINNER_TRACK_OPACITY);
251
+ host.remove();
252
+ });
235
253
  });
236
254
 
237
- await describe('adw-spinner sizing (adw_spinner_measure minimum = natural)', async () => {
255
+ await describe('adw-spinner sizing — the BOX and the RING are different things', async () => {
238
256
  for (const { value, size, rule } of SPINNER_SIZE_VECTORS) {
239
- await it(`size ${JSON.stringify(value ?? null)} → a ${spinnerGeometry(size, size).diameter}px ring — ${rule}`, () => {
257
+ await it(`size ${JSON.stringify(value ?? null)} → a ${size}px box — ${rule}`, () => {
240
258
  const { host } = mountSized(400);
241
259
  const spinner = document.createElement('adw-spinner');
242
260
  if (value !== null && value !== undefined) spinner.setAttribute('size', String(value));
243
261
  host.appendChild(spinner);
244
262
 
245
- expect(spinner.offsetWidth).toBe(spinnerGeometry(size, size).diameter);
263
+ // THE BOX takes the whole request — this suite used to assert the
264
+ // ring's diameter here, contradicting the core table it drives.
265
+ expect(spinner.offsetWidth).toBe(size);
266
+ expect(spinner.offsetHeight).toBe(size);
267
+ // THE RING is capped and centred inside it.
268
+ const svg = spinner.querySelector('svg') as SVGSVGElement;
269
+ expect(Number(svg.getAttribute('width'))).toBe(spinnerGeometry(size, size).diameter);
246
270
  host.remove();
247
271
  });
248
272
  }
249
273
 
274
+ await it('a 200px spinner occupies 200px and draws a 64px ring', () => {
275
+ const { host } = mountSized(400);
276
+ const spinner = document.createElement('adw-spinner');
277
+ spinner.setAttribute('size', '200');
278
+ host.appendChild(spinner);
279
+ expect(spinner.offsetWidth).toBe(200);
280
+ expect(Number((spinner.querySelector('svg') as SVGSVGElement).getAttribute('width'))).toBe(64);
281
+ host.remove();
282
+ });
283
+
250
284
  await it('defaults to the measured 16, not the invented 24', () => {
251
285
  const { host } = mountSized(400);
252
286
  const spinner = document.createElement('adw-spinner');
@@ -256,6 +290,71 @@ export const AdwChromeTest = async () => {
256
290
  });
257
291
  });
258
292
 
293
+ await describe('adw-spinner animation + accessibility (#1066)', async () => {
294
+ await it('announces itself — role progressbar, aria-busy (zero hits before)', () => {
295
+ const { host } = mountSized(400);
296
+ const spinner = document.createElement('adw-spinner');
297
+ host.appendChild(spinner);
298
+ expect(spinner.getAttribute('role')).toBe('progressbar');
299
+ expect(spinner.getAttribute('aria-busy')).toBe('true');
300
+ host.remove();
301
+ });
302
+
303
+ await it('BREATHES — the drawn arc length changes across a turn', () => {
304
+ const { host } = mountSized(400);
305
+ const spinner = document.createElement('adw-spinner') as HTMLElement & { drawAt(now: number): void };
306
+ spinner.setAttribute('size', '64');
307
+ host.appendChild(spinner);
308
+ const arc = spinner.querySelectorAll('circle')[1] as SVGCircleElement;
309
+
310
+ const drawnAt = (ms: number) => {
311
+ spinner.drawAt(ms);
312
+ return Number.parseFloat((arc.getAttribute('stroke-dasharray') ?? '0').split(' ')[0]);
313
+ };
314
+ // `drawAt` takes the FIRST call as the origin, so these are offsets
315
+ // into one animation, sampled across a cycle.
316
+ const lengths = [0, 200, 500, 900, 1300, 1900].map(drawnAt);
317
+ const spread = Math.max(...lengths) - Math.min(...lengths);
318
+ // A fixed 90-degree border chase — what this element used to draw —
319
+ // has a spread of exactly zero.
320
+ expect(spread).toBeGreaterThan(0);
321
+ host.remove();
322
+ });
323
+
324
+ await it('KEEPS SPINNING under reduced motion — a frozen busy indicator reads as a hang', () => {
325
+ // libadwaita opts the spinner out of the animation setting on purpose
326
+ // (adw-spinner-paintable.c:537). The old stylesheet had
327
+ // `@media (prefers-reduced-motion: reduce) { animation: none }`, so
328
+ // this asserts the ABSENCE of any CSS animation to switch off: the
329
+ // arc is driven per frame from JS and cannot be disabled by a query.
330
+ const { host } = mountSized(400);
331
+ const spinner = document.createElement('adw-spinner');
332
+ host.appendChild(spinner);
333
+ expect(getComputedStyle(spinner).animationName).toBe('none');
334
+ host.remove();
335
+ });
336
+
337
+ await it('advances while mapped and stops when it is not', () => {
338
+ const { host } = mountSized(400);
339
+ const spinner = document.createElement('adw-spinner') as HTMLElement & { drawAt(now: number): void };
340
+ host.appendChild(spinner);
341
+ const arc = spinner.querySelectorAll('circle')[1] as SVGCircleElement;
342
+ spinner.drawAt(0);
343
+ const first = arc.getAttribute('stroke-dashoffset');
344
+ spinner.drawAt(400);
345
+ expect(arc.getAttribute('stroke-dashoffset')).not.toBe(first);
346
+
347
+ // Unmapping removes it from the shared ticker — `widget_map_cb`
348
+ // (adw-spinner-paintable.c:181-185) is what plays the animation, so
349
+ // an off-screen spinner burns nothing.
350
+ spinner.remove();
351
+ const parked = arc.getAttribute('stroke-dashoffset');
352
+ spinner.drawAt(800); // only reachable directly; the ticker has dropped it
353
+ expect(arc.getAttribute('stroke-dashoffset')).not.toBe(parked);
354
+ host.remove();
355
+ });
356
+ });
357
+
259
358
  await describe('adw-toolbar-view style classes (update_undershoots + the style setters)', async () => {
260
359
  for (const { input, view, topBar, bottomBar, rule } of TOOLBAR_VIEW_CLASS_VECTORS) {
261
360
  await it(`${input.topBarStyle}/${input.bottomBarStyle} → [${view.join(' ')}] — ${rule}`, () => {
@@ -84,7 +84,17 @@ export class AdwActionRow extends HTMLElement {
84
84
  }
85
85
 
86
86
  connectedCallback() {
87
- if (this._initialized) return;
87
+ if (this._initialized) {
88
+ // Re-entering a document. `disconnectedCallback` drops the
89
+ // sensitivity observer, and returning here without rebuilding it
90
+ // left a MOVED row permanently blind to its activatable widget
91
+ // becoming insensitive — the same shape that broke the overlay
92
+ // split view's ResizeObserver and both view switchers' page
93
+ // observers. Anything torn down on the way out has to be rebuilt
94
+ // on the way back in.
95
+ this._observeSensitivity(this._state.activatableWidget);
96
+ return;
97
+ }
88
98
  this._initialized = true;
89
99
 
90
100
  const prefixChildren = Array.from(this.querySelectorAll(':scope > [slot="prefix"]'));
@@ -85,6 +85,7 @@ export class AdwComboRow extends HTMLElement {
85
85
  this._state.subscribe((change) => {
86
86
  this._valueEl.textContent = change.label;
87
87
  if (this._select.selectedIndex !== change.selected) this._select.selectedIndex = change.selected;
88
+ this._syncChooser();
88
89
  if (!change.interactive) return;
89
90
  this.setAttribute('selected', String(change.selected));
90
91
  this.dispatchEvent(
@@ -101,6 +102,7 @@ export class AdwComboRow extends HTMLElement {
101
102
  this._state.select(this._select.selectedIndex);
102
103
  });
103
104
 
105
+ this._syncChooser();
104
106
  this._renderText();
105
107
  }
106
108
 
@@ -115,6 +117,21 @@ export class AdwComboRow extends HTMLElement {
115
117
  }
116
118
  }
117
119
 
120
+ /**
121
+ * `model_changed` (adw-combo-row.c:187-195) — one option or none is not a
122
+ * choice, so the arrow goes and the row stops being activatable.
123
+ *
124
+ * Both halves have to be expressed: `data-presents-chooser` gates the
125
+ * `.adw-row-value::after` mask in the stylesheet, and DISABLING the overlaid
126
+ * `<select>` is what makes the row inert — hiding the arrow alone would leave
127
+ * a row that still opens a one-entry popup on click.
128
+ */
129
+ private _syncChooser() {
130
+ const presents = this._state.presentsChooser;
131
+ this.dataset.presentsChooser = presents ? 'true' : 'false';
132
+ this._select.disabled = !presents;
133
+ }
134
+
118
135
  private _renderText() {
119
136
  // The `string_is_not_empty` label rule, from core — this block was one of
120
137
  // six hand-rolled copies, all of which omitted the TITLE half.
@@ -53,6 +53,7 @@ import {
53
53
  isInlineViewSwitcherDisplayMode,
54
54
  toggleIndexForPage,
55
55
  } from '@gjsify/adwaita-core';
56
+ import type { ViewSwitcherScheduler } from '@gjsify/adwaita-core';
56
57
  import type {
57
58
  AdwInlineViewSwitcherDisplayMode,
58
59
  AdwViewSwitcherPage,
@@ -75,21 +76,43 @@ export class AdwViewStackPage extends HTMLElement {
75
76
  }
76
77
 
77
78
  export class AdwInlineViewSwitcher extends HTMLElement {
78
- private readonly _state = new ViewSwitcherState({ scheduler: domViewSwitcherScheduler });
79
+ /**
80
+ * The dwell timer, injectable.
81
+ *
82
+ * It used to be `new ViewSwitcherState({ scheduler: domViewSwitcherScheduler })`
83
+ * inline, which left NOWHERE to hand a clock in — so
84
+ * `VIEW_SWITCHER_DRAG_VECTORS` was driven by the core suite alone, and the
85
+ * conformance header claimed all three suites drove it. `scheduler` is a
86
+ * property so a test can replace it before the element connects; the DOM one
87
+ * is the default, exactly as before.
88
+ */
89
+ scheduler: ViewSwitcherScheduler = domViewSwitcherScheduler;
90
+ private _stateInstance: ViewSwitcherState | null = null;
91
+
92
+ /** Built on first access, so a `scheduler` set before connect is the one used. */
93
+ private get _state(): ViewSwitcherState {
94
+ if (!this._stateInstance) {
95
+ this._stateInstance = new ViewSwitcherState({ scheduler: this.scheduler });
96
+ this._stateInstance.subscribe((change) => this._onStateChange(change));
97
+ }
98
+ return this._stateInstance;
99
+ }
79
100
  private _groupEl!: HTMLDivElement;
80
101
  private _pagesEl!: HTMLDivElement;
81
102
  private _panels: HTMLDivElement[] = [];
82
103
  private _toggles: HTMLButtonElement[] = [];
83
104
  private _displayMode: AdwInlineViewSwitcherDisplayMode = 'labels';
84
105
  private _initialized = false;
106
+ /** The page ELEMENTS, kept because this element detaches them from the tree. */
107
+ private _declared: HTMLElement[] = [];
108
+ private _pageObserver: MutationObserver | undefined;
85
109
 
86
110
  static get observedAttributes() {
87
- return ['active', 'display-mode', 'flat', 'round'];
111
+ return ['active', 'display-mode', 'flat', 'round', 'osd'];
88
112
  }
89
113
 
90
114
  constructor() {
91
115
  super();
92
- this._state.subscribe((change) => this._onStateChange(change));
93
116
  }
94
117
 
95
118
  /** Zero-based index of the visible PAGE, `-1` when nothing is selected. */
@@ -147,7 +170,13 @@ export class AdwInlineViewSwitcher extends HTMLElement {
147
170
  }
148
171
 
149
172
  connectedCallback() {
150
- if (this._initialized) return;
173
+ if (this._initialized) {
174
+ // Re-entering a document — same reason as `adw-view-switcher.ts`:
175
+ // the page observer is dropped on disconnect, so it has to be
176
+ // rebuilt here or a moved switcher goes deaf permanently.
177
+ this._watchPages();
178
+ return;
179
+ }
151
180
 
152
181
  // `:scope >` rather than a subtree scan — `<adw-view-stack>` uses the
153
182
  // same tag for its pages, so an unscoped query adopted a nested stack's.
@@ -176,6 +205,8 @@ export class AdwInlineViewSwitcher extends HTMLElement {
176
205
  this._displayMode = this._readDisplayModeAttr();
177
206
  this._initialized = true;
178
207
 
208
+ this._declared = declared;
209
+ this._watchPages();
179
210
  this._state.setPages(declared.map((pageEl) => readSwitcherPage(pageEl)));
180
211
  if (declaredActive !== null) {
181
212
  const index = Number.parseInt(declaredActive, 10);
@@ -186,6 +217,37 @@ export class AdwInlineViewSwitcher extends HTMLElement {
186
217
 
187
218
  disconnectedCallback() {
188
219
  this._state.cancelDrag();
220
+ this._pageObserver?.disconnect();
221
+ this._pageObserver = undefined;
222
+ }
223
+
224
+ /**
225
+ * Re-read every declared page and push it into the state.
226
+ *
227
+ * Driven by {@link _watchPages}, which is what makes a runtime
228
+ * `badge-number` or `icon-name` change reach the toggle — C rebinds on every
229
+ * page `notify` (adw-view-switcher.c:184-193) and this element did not.
230
+ */
231
+ refreshPages(): void {
232
+ if (!this._initialized) return;
233
+ this._state.setPages(this._declared.map((pageEl) => readSwitcherPage(pageEl)));
234
+ this._rebuildToggles();
235
+ }
236
+
237
+ /**
238
+ * Watch the declared pages for attribute changes — the DOM's `notify::` on
239
+ * `AdwViewStackPage`, which C binds per page (adw-view-switcher.c:184-193).
240
+ *
241
+ * A `MutationObserver` rather than an `attributeChangedCallback`: this
242
+ * element consumes the page elements at connect and `replaceChildren`s them
243
+ * out of the tree, so a detached page cannot find its owner. It delivers on
244
+ * a MICROTASK where GTK's notify is synchronous — invisible to a user, and
245
+ * nothing here reads the toggle back inside the setter.
246
+ */
247
+ private _watchPages(): void {
248
+ this._pageObserver?.disconnect();
249
+ this._pageObserver = new MutationObserver(() => this.refreshPages());
250
+ for (const pageEl of this._declared) this._pageObserver.observe(pageEl, { attributes: true });
189
251
  }
190
252
 
191
253
  attributeChangedCallback(name: string) {
@@ -265,8 +327,21 @@ export class AdwInlineViewSwitcher extends HTMLElement {
265
327
  toggle.addEventListener('click', () => {
266
328
  this._state.setSelected(model.pageIndex);
267
329
  });
268
- toggle.addEventListener('dragenter', () => this._state.dragEnter(model.pageIndex));
269
- toggle.addEventListener('dragleave', () => this._state.dragLeave(model.pageIndex));
330
+ // The `relatedTarget` test is load-bearing: a toggle holds an icon,
331
+ // a label and an indicator, and the DOM fires `dragleave` +
332
+ // `dragenter` when the pointer crosses BETWEEN them — which cleared
333
+ // the dwell timer and re-armed a fresh 500ms, so a drag that merely
334
+ // slid across the toggle never switched the page. C has one
335
+ // `GtkDropControllerMotion` per BUTTON widget
336
+ // (adw-view-switcher-button.c:79-96), which has no internal edges.
337
+ toggle.addEventListener('dragenter', (event) => {
338
+ if (toggle.contains((event as DragEvent).relatedTarget as Node | null)) return;
339
+ this._state.dragEnter(model.pageIndex);
340
+ });
341
+ toggle.addEventListener('dragleave', (event) => {
342
+ if (toggle.contains((event as DragEvent).relatedTarget as Node | null)) return;
343
+ this._state.dragLeave(model.pageIndex);
344
+ });
270
345
 
271
346
  this._groupEl.appendChild(toggle);
272
347
  return toggle;
@@ -314,8 +389,16 @@ export class AdwInlineViewSwitcher extends HTMLElement {
314
389
  }
315
390
 
316
391
  private _applyStyleClasses(): void {
317
- this.classList.toggle('flat', this.hasAttribute('flat'));
318
- this.classList.toggle('round', this.hasAttribute('round'));
392
+ // `css_classes_changed_cb` (adw-inline-view-switcher.c:528-545) forwards
393
+ // THREE classes onto the toggle group, not two: `flat`, `round` and
394
+ // `osd`. This element carried the first two and only onto the host, so an
395
+ // `osd` inline switcher — the one in a floating overlay, where the class
396
+ // exists to make it legible — styled nothing at all.
397
+ for (const name of ['flat', 'round', 'osd'] as const) {
398
+ const present = this.hasAttribute(name);
399
+ this.classList.toggle(name, present);
400
+ this._groupEl?.classList.toggle(name, present);
401
+ }
319
402
  // The group carries the display-mode class, mirroring libadwaita's
320
403
  // inline-view-switcher > toggle-group.{labels,icons,both} (:826-850).
321
404
  for (const mode of INLINE_VIEW_SWITCHER_DISPLAY_MODES) {