@gjsify/adwaita-web 0.64.1 → 0.64.2-next.252

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 (83) hide show
  1. package/README.md +12 -8
  2. package/dist/adwaita-web.css +39 -9
  3. package/dist/adwaita-web.css.map +1 -1
  4. package/lib/types/application.d.ts +1 -1
  5. package/lib/types/child-slot.spec.d.ts +1 -0
  6. package/lib/types/container-fill.spec.d.ts +1 -0
  7. package/lib/types/elements/adw-alert-dialog.d.ts +77 -17
  8. package/lib/types/elements/adw-toast-overlay.d.ts +8 -29
  9. package/lib/types/elements/adw-window-features.d.ts +8 -0
  10. package/lib/types/elements/gtk-application-window.d.ts +1 -4
  11. package/lib/types/elements/gtk-emoji-chooser.d.ts +2 -0
  12. package/lib/types/elements/gtk-popover-menu.d.ts +2 -0
  13. package/lib/types/elements/gtk-popover.d.ts +5 -0
  14. package/lib/types/elements/gtk-stack-page.d.ts +28 -0
  15. package/lib/types/elements/gtk-stack.d.ts +11 -0
  16. package/lib/types/elements/gtk-window.d.ts +16 -1
  17. package/lib/types/gobject-elements.d.ts +9 -0
  18. package/lib/types/index.d.ts +2 -2
  19. package/lib/types/learn6502-window.spec.d.ts +1 -0
  20. package/lib/types/namespace/adw.d.ts +1 -1
  21. package/lib/types/namespace/gtk.d.ts +2 -0
  22. package/lib/types/shared-tree-builder.d.ts +31 -0
  23. package/lib/types/styles.generated.d.ts +1 -1
  24. package/lib/types/widget-visible.spec.d.ts +1 -0
  25. package/lib/types/window-maximize.spec.d.ts +1 -0
  26. package/package.json +13 -12
  27. package/scss/_bin.scss +6 -3
  28. package/scss/_overlay.scss +11 -1
  29. package/scss/_reset.scss +1 -1
  30. package/scss/_stack.scss +8 -2
  31. package/scss/_toast.scss +13 -3
  32. package/scss/_window.scss +16 -0
  33. package/src/adw-alert-dialog.spec.ts +71 -5
  34. package/src/adw-bin.spec.ts +4 -3
  35. package/src/adw-header-bar.spec.ts +18 -0
  36. package/src/adw-toast-overlay.spec.ts +68 -24
  37. package/src/application.spec.ts +44 -0
  38. package/src/application.ts +13 -18
  39. package/src/capabilities.mjs +1 -3
  40. package/src/child-slot.spec.ts +71 -0
  41. package/src/construct-vectors.spec.ts +51 -6
  42. package/src/container-fill.spec.ts +70 -0
  43. package/src/elements/adw-alert-dialog.ts +174 -44
  44. package/src/elements/adw-application-window.ts +0 -3
  45. package/src/elements/adw-breakpoint-bin.ts +3 -0
  46. package/src/elements/adw-dialog.ts +3 -1
  47. package/src/elements/adw-header-bar.ts +4 -0
  48. package/src/elements/adw-toast-overlay.ts +35 -69
  49. package/src/elements/adw-window-features.ts +14 -2
  50. package/src/elements/gtk-application-window.ts +3 -8
  51. package/src/elements/gtk-drag-icon.ts +4 -0
  52. package/src/elements/gtk-emoji-chooser.ts +5 -0
  53. package/src/elements/gtk-flow-box-child.ts +3 -1
  54. package/src/elements/gtk-graphics-offload.ts +3 -1
  55. package/src/elements/gtk-list-box-row.ts +3 -1
  56. package/src/elements/gtk-overlay.ts +5 -4
  57. package/src/elements/gtk-paned.ts +7 -1
  58. package/src/elements/gtk-popover-bin.ts +3 -0
  59. package/src/elements/gtk-popover-menu.ts +5 -0
  60. package/src/elements/gtk-popover.ts +12 -0
  61. package/src/elements/gtk-search-bar.ts +5 -5
  62. package/src/elements/gtk-stack-page.ts +111 -0
  63. package/src/elements/gtk-stack.ts +50 -5
  64. package/src/elements/gtk-tree-expander.ts +3 -0
  65. package/src/elements/gtk-viewport.ts +4 -0
  66. package/src/elements/gtk-window-handle.ts +4 -0
  67. package/src/elements/gtk-window.ts +55 -6
  68. package/src/gobject-elements.ts +28 -0
  69. package/src/gtk-stack.spec.ts +170 -0
  70. package/src/index.ts +2 -2
  71. package/src/keyboard-operable.spec.ts +3 -3
  72. package/src/learn6502-window.spec.ts +113 -0
  73. package/src/namespace/adw.ts +1 -1
  74. package/src/namespace/gtk.ts +2 -0
  75. package/src/shared-tree-builder.ts +105 -10
  76. package/src/shared-trees.spec.ts +15 -1
  77. package/src/slotted-children.spec.ts +24 -0
  78. package/src/slotted-children.ts +18 -2
  79. package/src/styles.generated.ts +1 -1
  80. package/src/test.browser.mts +10 -0
  81. package/src/value-lists.spec.ts +3 -3
  82. package/src/widget-visible.spec.ts +104 -0
  83. package/src/window-maximize.spec.ts +93 -0
@@ -92,6 +92,7 @@
92
92
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
93
93
 
94
94
  import type { DispatchedSignals } from '../signals.js';
95
+ import { STACK_PAGE_ATTRIBUTES, pageChildOf } from './gtk-stack-page.js';
95
96
 
96
97
  /** `GtkStackTransitionType`, spelled as the GIR's enum nicks (gtkstack.c:95-121). */
97
98
  export type StackTransitionType =
@@ -416,7 +417,7 @@ export class GtkStack extends HTMLElement {
416
417
  }
417
418
  this._initialized = true;
418
419
  this.setAttribute('role', 'group');
419
- for (const child of this._children()) this._initChild(child);
420
+ for (const child of this._children()) this._initChild(this._adopt(child));
420
421
  // `add_page` connects the stack to each page's `notify::visible`
421
422
  // (gtkstack.c:1771), which is the browser's mutation observer.
422
423
  this._observer = new MutationObserver((records) => this._onChildMutations(records));
@@ -531,12 +532,44 @@ export class GtkStack extends HTMLElement {
531
532
  return this._children().find((child) => this._page(child).visible) ?? null;
532
533
  }
533
534
 
535
+ /**
536
+ * A `<gtk-stack-page>` record, adopted: its child takes the record's place and its properties
537
+ * as the attributes this element reads, the way `<adw-view-stack>` adopts a declared page.
538
+ * Any other child is already the page and is handed back as it is.
539
+ */
540
+ private _adopt(child: HTMLElement): HTMLElement {
541
+ if (child.localName !== 'gtk-stack-page') return child;
542
+ const content = pageChildOf(child);
543
+ if (content === null) {
544
+ throw new Error(
545
+ `<gtk-stack-page name="${child.getAttribute('name') ?? ''}"> has no child, so the stack would ` +
546
+ 'hold a page with nothing to show.',
547
+ );
548
+ }
549
+ for (const name of STACK_PAGE_ATTRIBUTES) {
550
+ const value = child.getAttribute(name);
551
+ if (value !== null) content.setAttribute(name, value);
552
+ }
553
+ if (child.hasAttribute('hidden')) content.setAttribute('hidden', '');
554
+ content.removeAttribute('slot');
555
+ this.replaceChild(content, child);
556
+ return content;
557
+ }
558
+
534
559
  private _initChild(child: HTMLElement): void {
535
560
  if (this._initializedChildren.has(child)) return;
536
561
  this._initializedChildren.add(child);
537
562
  child.classList.add('adw-stack-page');
538
563
  }
539
564
 
565
+ /**
566
+ * The observer watches the subtree, so an attribute record can come from anything below a
567
+ * page; only a direct child is a page of THIS stack (a nested stack's pages are its own).
568
+ */
569
+ private _isPage(node: Node): boolean {
570
+ return node.parentNode === this;
571
+ }
572
+
540
573
  private _onChildMutations(records: MutationRecord[]): void {
541
574
  let added = 0;
542
575
  let removed = 0;
@@ -544,13 +577,24 @@ export class GtkStack extends HTMLElement {
544
577
  let pageUpdated = false;
545
578
  for (const record of records) {
546
579
  if (record.type === 'childList') {
580
+ // The observer watches the subtree for page attributes; a child list changing
581
+ // below a page (an adopted record handing over its child) is not a page change.
582
+ if (record.target !== this) continue;
547
583
  for (const node of Array.from(record.removedNodes)) {
584
+ // A record leaving as its child takes its place was never a page here.
585
+ if (node instanceof HTMLElement && node.localName === 'gtk-stack-page') continue;
548
586
  if (node === this._visible) this._visible = null;
549
587
  if (node === this._lastVisible) this._lastVisible = null;
550
588
  removed += 1;
551
589
  }
552
590
  for (const node of Array.from(record.addedNodes)) {
553
591
  if (!(node instanceof HTMLElement)) continue;
592
+ // An adopted record is replaced by its child, and that replacement is the
593
+ // childList record the page is counted in.
594
+ if (node.localName === 'gtk-stack-page') {
595
+ if (node.parentElement === this) this._adopt(node);
596
+ continue;
597
+ }
554
598
  this._initChild(node);
555
599
  added += 1;
556
600
  if (position === -1) position = this._children().indexOf(node);
@@ -558,13 +602,14 @@ export class GtkStack extends HTMLElement {
558
602
  continue;
559
603
  }
560
604
  const name = record.attributeName;
561
- if (!name) continue;
605
+ if (!name || !this._isPage(record.target)) continue;
562
606
  if (name === 'hidden') {
563
607
  // `stack_child_visibility_notify_cb` (gtkstack.c:1617-1633) re-runs
564
608
  // `update_child_visible`: hiding the visible page falls back to the first
565
609
  // visible one (which is what the NULL means, :1437-1450), and showing one
566
- // while none is visible picks it up (:3147-3149).
567
- this._show(this._firstVisible());
610
+ // while none is visible picks it up (:3147-3149). Any other change leaves
611
+ // the selection where it is.
612
+ if (!this._visible || !this._page(this._visible).visible) this._show(this._firstVisible());
568
613
  continue;
569
614
  }
570
615
  if (PAGE_ATTRIBUTES.includes(name)) pageUpdated = true;
@@ -579,7 +624,7 @@ export class GtkStack extends HTMLElement {
579
624
  }
580
625
  if (pageUpdated) {
581
626
  for (const record of records) {
582
- if (record.type !== 'attributes') continue;
627
+ if (record.type !== 'attributes' || !this._isPage(record.target)) continue;
583
628
  const target = record.target as HTMLElement;
584
629
  this.dispatchEvent(new CustomEvent('page-updated', { bubbles: true, detail: { child: target } }));
585
630
  // `gtk_stack_page_set_name` re-notifies `visible-child-name` when the
@@ -59,6 +59,7 @@ import {
59
59
  } from '@gjsify/adwaita-core';
60
60
 
61
61
  import { createGtkImage } from './gtk-image.js';
62
+ import { bindSlottedChildren } from '../slotted-children.js';
62
63
 
63
64
  /** `TIMEOUT_EXPAND` — how long a drag hovers before the row opens (gtktreeexpander.c:717). */
64
65
  const TIMEOUT_EXPAND = 500;
@@ -142,6 +143,8 @@ export class GtkTreeExpander extends HTMLElement {
142
143
  if (!this.hasAttribute('tabindex')) this.tabIndex = 0;
143
144
  this._wire();
144
145
  }
146
+ // The NAME only, for the reason `adw-bin.ts` gives: the child already IS the content.
147
+ bindSlottedChildren(this, [{ name: 'child', into: this }]);
145
148
  this.setAttribute('role', 'button');
146
149
  this._render();
147
150
  }
@@ -65,6 +65,8 @@
65
65
 
66
66
  import { clampAdjustmentValue, normalizeAdjustment, type AdwAdjustment } from '@gjsify/adwaita-core';
67
67
 
68
+ import { bindSlottedChildren } from '../slotted-children.js';
69
+
68
70
  /** `viewport_set_adjustment_values`'s two fractions of the visible size. */
69
71
  const STEP_FRACTION = 0.1;
70
72
  const PAGE_FRACTION = 0.9;
@@ -130,6 +132,8 @@ export class GtkViewport extends HTMLElement {
130
132
  }
131
133
 
132
134
  connectedCallback() {
135
+ // The NAME only, for the reason `adw-bin.ts` gives: the child already IS the content.
136
+ bindSlottedChildren(this, [{ name: 'child', into: this }]);
133
137
  this.setAttribute('role', 'generic');
134
138
  // `scroll` does not bubble, so the platform's own is heard here, on the node that
135
139
  // raised it, and re-announced as GTK's `GtkAdjustment::value-changed`.
@@ -46,6 +46,8 @@
46
46
  // Copyright (c) The GTK Team. LGPLv2.1+.
47
47
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
48
48
 
49
+ import { bindSlottedChildren } from '../slotted-children.js';
50
+
49
51
  /** `GtkTitlebarGesture`'s three gestures, as `perform_titlebar_action` decides them. */
50
52
  export type GtkTitlebarGesture = 'double-click' | 'middle-click' | 'right-click';
51
53
 
@@ -146,6 +148,8 @@ export class GtkWindowHandle extends HTMLElement {
146
148
  }
147
149
 
148
150
  connectedCallback() {
151
+ // The NAME only, for the reason `adw-bin.ts` gives: the child already IS the content.
152
+ bindSlottedChildren(this, [{ name: 'child', into: this }]);
149
153
  this.setAttribute('role', 'generic');
150
154
  this.addEventListener('click', this._onClick);
151
155
  this.addEventListener('pointerdown', this._onPointerDown);
@@ -47,7 +47,7 @@
47
47
  // difference a reader of the DOM can see.
48
48
  //
49
49
  // NOT PORTED, and why — these are the KNOWN_GAPS of this element:
50
- // `application`, `child`, `default-widget`, `display` — WIDGET- or
50
+ // `child`, `default-widget`, `display` — WIDGET- or
51
51
  // DISPLAY-typed, so a slot or an id here rather than an attribute; `child` and
52
52
  // `titlebar` ARE slots and `transient-for` only has to be PRESENT.
53
53
  // `destroy-with-parent` — a DOM node has no destroy, and a page has no parent to destroy.
@@ -69,8 +69,14 @@
69
69
  // `set_focus(el)` calls `el.focus()` and refuses an element outside the frame;
70
70
  // `set_focus(null)` blurs. `focusin`/`focusout` notify; focus that merely
71
71
  // leaves with the window (blur) keeps its widget, as GTK keeps it.
72
- // `maximized` — the FRAME's state (square corners), not the browser window's: a page cannot
73
- // maximize its browser window. `is_maximized()` reads it.
72
+ // `maximized` — the FRAME's state, not the browser window's: a page cannot maximize its
73
+ // browser window. Square corners, and — as in GTK, where a maximized or
74
+ // fullscreen window ignores its default size and fills the work area — the frame
75
+ // fills its CONTAINING BLOCK (the viewport for a frame in a full-height body)
76
+ // until it is unmaximized, which restores `default-width`/`default-height`.
77
+ // `fullscreened` does the same. `maximize()`, `unmaximize()`, `fullscreen()`,
78
+ // `unfullscreen()` and `toggle-maximized` all write the attribute. `is_maximized()`
79
+ // reads it.
74
80
  // `present()` — un-hides the frame; a page cannot raise or focus a window it does not own.
75
81
  //
76
82
  // THE GOBJECT DOOR (ADR 0096 § 3, ADR 0098 Amendment 3): `connect` / `disconnect` / `emit` /
@@ -92,6 +98,7 @@
92
98
 
93
99
  import { CloseRequestState, WindowMemberState, type WindowMemberProperty } from '@gjsify/adwaita-core';
94
100
 
101
+ import { defineWindowApplication, type GtkApplication } from '../application.js';
95
102
  import { installElementApi } from '../gobject-elements.js';
96
103
  import type { DispatchedSignals } from '../signals.js';
97
104
  import { bindSlottedChildren, type AdwSlot } from '../slotted-children.js';
@@ -99,6 +106,7 @@ import { bindSlottedChildren, type AdwSlot } from '../slotted-children.js';
99
106
  // Registers <gtk-window-controls>: a frame with no titlebar of its own draws the two of them
100
107
  // a server-side decoration would have, so importing the window alone must define them.
101
108
  import './gtk-window-controls.js';
109
+ import { fillsContainingBlock } from './adw-window-features.js';
102
110
  import type { WindowAction } from './gtk-window-controls.js';
103
111
 
104
112
  export class GtkWindow extends HTMLElement {
@@ -138,6 +146,7 @@ export class GtkWindow extends HTMLElement {
138
146
  'close-request': 'close-request',
139
147
  ...Object.fromEntries(
140
148
  [
149
+ 'application',
141
150
  'decorated',
142
151
  'default-height',
143
152
  'default-width',
@@ -156,6 +165,11 @@ export class GtkWindow extends HTMLElement {
156
165
  ),
157
166
  };
158
167
 
168
+ /** `Gtk.Window:application`: setting it is `add_window` (ADR 0098), set by `defineWindowApplication`. */
169
+ declare application: GtkApplication | null;
170
+ declare get_application: () => GtkApplication | null;
171
+ declare set_application: (application: GtkApplication | null) => void;
172
+
159
173
  /** `GObject.Object.connect`: installed per instance by `installElementApi`; refuses an undeclared signal. */
160
174
  declare connect: (signal: string, handler: (emitter: this, ...args: any[]) => unknown) => number;
161
175
  /** `GObject.Object.disconnect`: throws for an id that is not connected. */
@@ -239,6 +253,15 @@ export class GtkWindow extends HTMLElement {
239
253
  this._write('maximized', String(value));
240
254
  }
241
255
 
256
+ /** `Gtk.Window:fullscreened` — square corners, and the frame fills its containing block. */
257
+ get fullscreened(): boolean {
258
+ return this._offByDefault('fullscreened');
259
+ }
260
+
261
+ set fullscreened(value: boolean) {
262
+ this._write('fullscreened', String(value));
263
+ }
264
+
242
265
  /** `Gtk.Window:resizable` — whether the maximize button may appear. */
243
266
  get resizable(): boolean {
244
267
  return this._onByDefault('resizable');
@@ -300,7 +323,14 @@ export class GtkWindow extends HTMLElement {
300
323
  this._content.className = 'adw-gtk-window-content';
301
324
 
302
325
  bindSlottedChildren(this, [
303
- ...(this.hasTitlebar() ? [{ name: 'titlebar', into: this._titlebar }] : []),
326
+ // `child` only beside a titlebar: AdwWindow and AdwApplicationWindow inherit
327
+ // `Gtk.Window:child` and abort on it (adw-window.c:180), `content` is theirs.
328
+ ...(this.hasTitlebar()
329
+ ? [
330
+ { name: 'titlebar', into: this._titlebar },
331
+ { name: 'child', into: this._content },
332
+ ]
333
+ : []),
304
334
  ...this.extraSlots(),
305
335
  { into: this._content },
306
336
  ]).install(...this.strips());
@@ -431,16 +461,18 @@ export class GtkWindow extends HTMLElement {
431
461
 
432
462
  /**
433
463
  * The frame's box from `default-width` / `default-height`; an absent one clears it. A subclass
434
- * with a size request of its own (`Adw.Window`'s 360×200) replaces this.
464
+ * with a size request of its own (`Adw.Window`'s 360×200) replaces this. A maximized or
465
+ * fullscreened frame carries no inline size: the skin fills its containing block.
435
466
  */
436
467
  protected renderFrameSize(): void {
468
+ const fills = fillsContainingBlock(this);
437
469
  const width = Number.parseFloat(this.getAttribute('default-width') ?? '');
438
470
  const height = Number.parseFloat(this.getAttribute('default-height') ?? '');
439
471
  for (const [axis, value] of [
440
472
  ['width', width],
441
473
  ['height', height],
442
474
  ] as const) {
443
- if (Number.isFinite(value) && value > 0) this.style.setProperty(axis, `${value}px`);
475
+ if (!fills && Number.isFinite(value) && value > 0) this.style.setProperty(axis, `${value}px`);
444
476
  else this.style.removeProperty(axis);
445
477
  }
446
478
  }
@@ -598,6 +630,23 @@ export class GtkWindow extends HTMLElement {
598
630
  unmaximize(): void {
599
631
  this._write('maximized', 'false');
600
632
  }
633
+
634
+ /** `gtk_window_is_fullscreen`: the FRAME's state, like {@link is_maximized}. */
635
+ is_fullscreen(): boolean {
636
+ return this.fullscreened;
637
+ }
638
+
639
+ /** `gtk_window_fullscreen`; asynchronous in GTK, so the property is written directly. */
640
+ fullscreen(): void {
641
+ this._write('fullscreened', 'true');
642
+ }
643
+
644
+ /** `gtk_window_unfullscreen`, with the same caveat as {@link fullscreen}. */
645
+ unfullscreen(): void {
646
+ this._write('fullscreened', 'false');
647
+ }
601
648
  }
602
649
 
603
650
  customElements.define('gtk-window', GtkWindow);
651
+
652
+ defineWindowApplication(GtkWindow.prototype);
@@ -154,6 +154,34 @@ export function installSensitive(el: HTMLElement): void {
154
154
  });
155
155
  }
156
156
 
157
+ const VISIBLE_INSTALLED = new WeakSet<Element>();
158
+
159
+ /** Whether `installVisible` gave `el` its `visible` (an element with its own keeps its own). */
160
+ export function hasInstalledVisible(el: Element): boolean {
161
+ return VISIBLE_INSTALLED.has(el);
162
+ }
163
+
164
+ /**
165
+ * `Gtk.Widget:visible` for an element that has no member of that name: `gtk_widget_get_visible`
166
+ * and `gtk_widget_set_visible` over the DOM's `hidden` attribute, TRUE until hidden as GtkWidget's
167
+ * default is. It notifies only where the element declares `notify::visible`. An element that already declares `visible` (`<gtk-stack-page>`, `<adw-sidebar>`)
168
+ * keeps its own, and so does a registered class whose prototype has the port's members.
169
+ */
170
+ export function installVisible(el: HTMLElement): void {
171
+ if ('visible' in el) return;
172
+ VISIBLE_INSTALLED.add(el);
173
+ const define = (name: string, descriptor: PropertyDescriptor) =>
174
+ Object.defineProperty(el, name, { configurable: true, enumerable: false, ...descriptor });
175
+ const set = (value: unknown): void => {
176
+ const before = !el.hidden;
177
+ el.toggleAttribute('hidden', !value);
178
+ if (!el.hidden !== before && 'notify::visible' in dispatchedSignalsOf(el)) notifyElement(el, 'visible');
179
+ };
180
+ define('visible', { get: () => !el.hidden, set });
181
+ if (!('get_visible' in el)) define('get_visible', { value: () => !el.hidden, writable: true });
182
+ if (!('set_visible' in el)) define('set_visible', { value: set, writable: true });
183
+ }
184
+
157
185
  /** Gives `el` the instance API of a registered class, over the events it declares. */
158
186
  export function installElementApi(el: HTMLElement): void {
159
187
  // A registered class already has the core's four methods on its prototype.
@@ -7,6 +7,8 @@
7
7
  import { describe, expect, it } from '@gjsify/unit';
8
8
 
9
9
  import type { GtkStack } from './elements/gtk-stack.js';
10
+ import { pageChildOf } from './elements/gtk-stack-page.js';
11
+ import { mountSharedTree } from './shared-tree-builder.js';
10
12
 
11
13
  interface Page {
12
14
  name: string;
@@ -144,6 +146,66 @@ export const GtkStackTest = async () => {
144
146
  host.remove();
145
147
  });
146
148
 
149
+ await it('a descendant toggling hidden keeps the selection', async () => {
150
+ // gtkstack.c:1617-1633 listens to the PAGE's own visibility, never to what is
151
+ // nested inside it (an `adw-view-stack` showing and hiding its own pages).
152
+ const { el, host } = mount([
153
+ { name: 'single', title: 'Single' },
154
+ { name: 'three', title: 'Three' },
155
+ ]);
156
+ const nested = document.createElement('span');
157
+ el.children[0]!.appendChild(nested);
158
+ el.setVisibleChildName('three');
159
+ nested.setAttribute('hidden', '');
160
+ await settle();
161
+ nested.removeAttribute('hidden');
162
+ await settle();
163
+ expect(el.visibleChildName).toBe('three');
164
+ expect(marks(el)).toStrictEqual(['single', 'three:visible']);
165
+ host.remove();
166
+ });
167
+
168
+ await it('hiding a page that is not visible keeps the selection', async () => {
169
+ const { el, host } = mount([
170
+ { name: 'a', title: 'Alpha' },
171
+ { name: 'b', title: 'Beta' },
172
+ { name: 'c', title: 'Gamma' },
173
+ ]);
174
+ el.setVisibleChildName('c');
175
+ (el.children[1] as HTMLElement).setAttribute('hidden', '');
176
+ await settle();
177
+ expect(el.visibleChildName).toBe('c');
178
+ host.remove();
179
+ });
180
+
181
+ await it('hiding the visible page after a selection falls back to the first visible', async () => {
182
+ const { el, host } = mount([
183
+ { name: 'a', title: 'Alpha' },
184
+ { name: 'b', title: 'Beta' },
185
+ { name: 'c', title: 'Gamma' },
186
+ ]);
187
+ el.setVisibleChildName('c');
188
+ (el.children[0] as HTMLElement).setAttribute('hidden', '');
189
+ (el.children[2] as HTMLElement).setAttribute('hidden', '');
190
+ await settle();
191
+ expect(el.visibleChildName).toBe('b');
192
+ host.remove();
193
+ });
194
+
195
+ await it('a descendant name change dispatches no page-updated', async () => {
196
+ const { el, host } = mount([{ name: 'a', title: 'Alpha' }]);
197
+ const nested = document.createElement('span');
198
+ el.children[0]!.appendChild(nested);
199
+ await settle();
200
+ const events: string[] = [];
201
+ el.addEventListener('page-updated', () => events.push('updated'));
202
+ nested.setAttribute('name', 'inner');
203
+ await settle();
204
+ expect(events).toStrictEqual([]);
205
+ expect(el.visibleChildName).toBe('a');
206
+ host.remove();
207
+ });
208
+
147
209
  await it('re-selecting the visible page is not a change', () => {
148
210
  // gtkstack.c:1452-1453 — no notify, no transition.
149
211
  const { el, host } = mount([
@@ -429,4 +491,112 @@ export const GtkStackTest = async () => {
429
491
  host.remove();
430
492
  });
431
493
  });
494
+
495
+ await describe('<gtk-stack> adopts a <gtk-stack-page> record', async () => {
496
+ const record = (attrs: Record<string, string>, body = 'page'): HTMLElement => {
497
+ const page = document.createElement('gtk-stack-page');
498
+ for (const [name, value] of Object.entries(attrs)) page.setAttribute(name, value);
499
+ const child = document.createElement('div');
500
+ child.setAttribute('slot', 'child');
501
+ child.textContent = body;
502
+ page.appendChild(child);
503
+ return page;
504
+ };
505
+
506
+ await it('puts the child where the record was, with its properties as attributes', () => {
507
+ const host = document.createElement('div');
508
+ document.body.appendChild(host);
509
+ const el = document.createElement('gtk-stack') as GtkStack;
510
+ el.appendChild(
511
+ record({
512
+ name: 'a',
513
+ title: 'Alpha',
514
+ 'icon-name': 'go-home',
515
+ 'needs-attention': '',
516
+ 'use-underline': '',
517
+ }),
518
+ );
519
+ el.appendChild(record({ name: 'b' }, 'beta'));
520
+ host.appendChild(el);
521
+ expect(el.querySelector('gtk-stack-page')).toBe(null);
522
+ expect(marks(el)).toStrictEqual(['a:visible', 'b']);
523
+ const [page] = el.pages;
524
+ expect(page!.title).toBe('Alpha');
525
+ expect(page!.iconName).toBe('go-home');
526
+ expect(page!.needsAttention).toBe(true);
527
+ expect(page!.useUnderline).toBe(true);
528
+ expect(page!.child.hasAttribute('slot')).toBe(false);
529
+ expect(el.getChildByName('b')!.textContent).toBe('beta');
530
+ host.remove();
531
+ });
532
+
533
+ await it('reads a hidden record as GtkStackPage:visible = FALSE', () => {
534
+ const host = document.createElement('div');
535
+ document.body.appendChild(host);
536
+ const el = document.createElement('gtk-stack') as GtkStack;
537
+ const hidden = record({ name: 'a' });
538
+ (hidden as unknown as { visible: boolean }).visible = false;
539
+ el.appendChild(hidden);
540
+ el.appendChild(record({ name: 'b' }));
541
+ host.appendChild(el);
542
+ expect(el.pages.map((page) => page.visible)).toStrictEqual([false, true]);
543
+ expect(el.visibleChildName).toBe('b');
544
+ host.remove();
545
+ });
546
+
547
+ await it('adopts a record appended after connect, counted once', async () => {
548
+ const { el, host } = mount([{ name: 'a' }]);
549
+ const events: unknown[] = [];
550
+ el.addEventListener('items-changed', (e) => events.push((e as CustomEvent).detail));
551
+ el.appendChild(record({ name: 'b' }));
552
+ await settle();
553
+ await settle();
554
+ expect(marks(el)).toStrictEqual(['a:visible', 'b']);
555
+ expect(events).toStrictEqual([{ position: 1, removed: 0, added: 1 }]);
556
+ host.remove();
557
+ });
558
+
559
+ await it('takes a bare child as the page, and refuses a record with two', () => {
560
+ const bare = document.createElement('gtk-stack-page');
561
+ const child = document.createElement('div');
562
+ bare.appendChild(child);
563
+ expect(pageChildOf(bare)).toBe(child);
564
+ bare.appendChild(document.createElement('div'));
565
+ expect(() => pageChildOf(bare)).toThrow('has one `child`');
566
+ expect(pageChildOf(document.createElement('gtk-stack-page'))).toBe(null);
567
+ });
568
+
569
+ await it('builds `Stack { StackPage { name; child: … } }` from a shared tree', () => {
570
+ const { root, unmount } = mountSharedTree({
571
+ tag: 'GtkStack',
572
+ id: 'layoutHost',
573
+ children: [
574
+ {
575
+ tag: 'GtkStackPage',
576
+ id: 'singlePage',
577
+ props: { name: 'single' },
578
+ children: [{ tag: 'AdwBin', slot: 'child', id: 'singleBin' }],
579
+ },
580
+ {
581
+ tag: 'GtkStackPage',
582
+ id: 'threePage',
583
+ props: { name: 'three', title: 'Three' },
584
+ children: [{ tag: 'AdwBin', slot: 'child', id: 'threeBin' }],
585
+ },
586
+ ],
587
+ });
588
+ try {
589
+ const stack = root as GtkStack;
590
+ expect(stack.pages.map((page) => page.child.id)).toStrictEqual(['singleBin', 'threeBin']);
591
+ // The first page added is the visible one (gtkstack.c:1773-1775); a breakpoint
592
+ // setter selects another by the name its record carried.
593
+ expect(stack.visibleChild!.id).toBe('singleBin');
594
+ expect(stack.setVisibleChildName('three')).toBe(true);
595
+ expect(stack.visibleChild!.id).toBe('threeBin');
596
+ expect(stack.getPage(stack.visibleChild!)!.title).toBe('Three');
597
+ } finally {
598
+ unmount();
599
+ }
600
+ });
601
+ });
432
602
  };
package/src/index.ts CHANGED
@@ -80,8 +80,8 @@ export type { AdwUndershootEdges } from './scroll-shading.js';
80
80
  // replaying an authored tree — a storybook fixture, a devtools probe, the shared-trees
81
81
  // spec itself — has a route to it that is not a `.spec.ts` import; see that file's header
82
82
  // for why `mountSharedTree` and not `buildSharedTree` alone is the instantiation half.
83
- export { buildSharedTree, mountSharedTree } from './shared-tree-builder.js';
84
- export type { MountOptions, MountedSharedTree, SizeSource } from './shared-tree-builder.js';
83
+ export { buildSharedTree, buildWithSiblings, mountSharedTree } from './shared-tree-builder.js';
84
+ export type { BuildOptions, BuiltTree, MountOptions, MountedSharedTree, SizeSource } from './shared-tree-builder.js';
85
85
  export { registerTemplateClass } from './template-classes.js';
86
86
 
87
87
  // The stylesheet compiles a chosen SUBSET of `@gjsify/adwaita-icons` (the whole set is
@@ -314,8 +314,8 @@ export const AdwKeyboardOperableTest = async () => {
314
314
  '<adw-alert-response id="ok">OK</adw-alert-response>' +
315
315
  '<adw-alert-response id="cancel">Cancel</adw-alert-response>',
316
316
  );
317
- el.setDefaultResponse('ok');
318
- el.setResponseEnabled('ok', false);
317
+ el.set_default_response('ok');
318
+ el.set_response_enabled('ok', false);
319
319
  opener.focus();
320
320
  el.setAttribute('open', '');
321
321
 
@@ -344,7 +344,7 @@ export const AdwKeyboardOperableTest = async () => {
344
344
  '<adw-alert-response id="ok">OK</adw-alert-response>' +
345
345
  '<adw-alert-response id="cancel">Cancel</adw-alert-response>',
346
346
  );
347
- el.setDefaultResponse('ok');
347
+ el.set_default_response('ok');
348
348
  el.setAttribute('open', '');
349
349
 
350
350
  // `adw_alert_dialog_grab_focus` tries the CONTENT first (adw-alert-dialog.c:397)