@gjsify/adwaita-web 0.43.0 → 0.44.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.
@@ -14,9 +14,21 @@
14
14
  // Attributes:
15
15
  // heading (the bold heading text)
16
16
  // body (the body text below the heading)
17
+ // heading-use-markup (boolean — read `heading` as markup rather than plain text)
18
+ // body-use-markup (boolean — read `body` as markup rather than plain text)
19
+ // close-response (the response ID emitted on dismissal; default `close`)
20
+ // default-response (the response ID whose button is highlighted and focused)
17
21
  // open (boolean — whether the dialog is shown; default closed)
18
22
  // prefer-wide-layout (boolean — lay responses out horizontally when they fit)
19
23
  //
24
+ // MARKUP IS OPT-IN, and here that MATCHES libadwaita rather than departing from it:
25
+ // `Adw.AlertDialog:heading-use-markup` and `:body-use-markup` both default to FALSE
26
+ // (`Adw.Banner:use-markup` is the one that defaults to TRUE, which is why
27
+ // `adw-banner.ts` documents a deliberate departure and this file does not need to).
28
+ // Opting in renders through `innerHTML`, which is the author asserting the string is
29
+ // trusted — Pango markup has a fixed tag set and no scripting, HTML has neither
30
+ // property.
31
+ //
20
32
  // Buttons are stacked vertically by default (the Adw.AlertDialog default at medium
21
33
  // sizes) and laid out horizontally when prefer-wide-layout is set and there are no more
22
34
  // than two of them — the libadwaita measure/allocate heuristic in spirit, not to the px.
@@ -45,6 +57,7 @@ import { AdwAlertResponses } from '@gjsify/adwaita-core';
45
57
  import type { AdwResponseAppearance } from '@gjsify/adwaita-core';
46
58
 
47
59
  import { bindEmptySections } from '../empty-sections.js';
60
+ import { bindSlottedChildren } from '../slotted-children.js';
48
61
  import { AdwModalSurface } from './modal-surface.js';
49
62
 
50
63
  /** Response button appearance — mirrors Adw.ResponseAppearance. */
@@ -81,7 +94,16 @@ export class AdwAlertDialog extends HTMLElement {
81
94
  private _blockCloseResponse = false;
82
95
 
83
96
  static get observedAttributes() {
84
- return ['heading', 'body', 'open', 'prefer-wide-layout'];
97
+ return [
98
+ 'heading',
99
+ 'body',
100
+ 'heading-use-markup',
101
+ 'body-use-markup',
102
+ 'close-response',
103
+ 'default-response',
104
+ 'open',
105
+ 'prefer-wide-layout',
106
+ ];
85
107
  }
86
108
 
87
109
  get heading(): string {
@@ -109,6 +131,26 @@ export class AdwAlertDialog extends HTMLElement {
109
131
  else this.removeAttribute('open');
110
132
  }
111
133
 
134
+ /** Whether {@link heading} is read as markup rather than plain text. */
135
+ get headingUseMarkup(): boolean {
136
+ return this.hasAttribute('heading-use-markup');
137
+ }
138
+
139
+ set headingUseMarkup(value: boolean) {
140
+ if (value) this.setAttribute('heading-use-markup', '');
141
+ else this.removeAttribute('heading-use-markup');
142
+ }
143
+
144
+ /** Whether {@link body} is read as markup rather than plain text. */
145
+ get bodyUseMarkup(): boolean {
146
+ return this.hasAttribute('body-use-markup');
147
+ }
148
+
149
+ set bodyUseMarkup(value: boolean) {
150
+ if (value) this.setAttribute('body-use-markup', '');
151
+ else this.removeAttribute('body-use-markup');
152
+ }
153
+
112
154
  /** Whether to prefer horizontal button layout when the buttons fit. */
113
155
  get preferWideLayout(): boolean {
114
156
  return this.hasAttribute('prefer-wide-layout');
@@ -141,12 +183,6 @@ export class AdwAlertDialog extends HTMLElement {
141
183
  if (this._initialized) return;
142
184
  this._initialized = true;
143
185
 
144
- // Declared <adw-alert-response> children are snapshotted before the element takes
145
- // over the subtree; everything else becomes the extra child.
146
- const declared = Array.from(this.querySelectorAll(':scope > adw-alert-response')) as AdwAlertResponse[];
147
- const declaredSet = new Set<Node>(declared);
148
- const extraChildren = Array.from(this.childNodes).filter((n) => !declaredSet.has(n));
149
-
150
186
  this._scrimEl = document.createElement('div');
151
187
  this._scrimEl.className = 'adw-alert-dialog-scrim';
152
188
  this._scrimEl.addEventListener('click', () => this._dismiss());
@@ -176,36 +212,66 @@ export class AdwAlertDialog extends HTMLElement {
176
212
 
177
213
  this._childEl = document.createElement('div');
178
214
  this._childEl.className = 'adw-alert-dialog-child';
179
- for (const child of extraChildren) this._childEl.appendChild(child);
180
215
 
181
216
  messageArea.append(this._headingEl, this._bodyEl, this._childEl);
182
- // `_render` runs from `attributeChangedCallback` and from the response API, and
183
- // an extra child appended after connect triggers neither.
184
- bindEmptySections(this._childEl);
185
217
 
186
218
  this._responseAreaEl = document.createElement('div');
187
219
  this._responseAreaEl.className = 'adw-alert-dialog-responses';
188
220
 
189
221
  this._dialogEl.append(messageArea, this._responseAreaEl);
190
- this.replaceChildren(this._scrimEl, this._dialogEl);
191
-
192
- for (const el of declared) {
193
- const id = el.getAttribute('id');
194
- if (!id) continue;
195
- const appearance = (el.getAttribute('appearance') ?? 'default') as AdwResponseAppearance;
196
- this.addResponse(id, (el.textContent ?? '').trim());
197
- if (appearance !== 'default') this.setResponseAppearance(id, appearance);
198
- if (el.getAttribute('enabled') === 'false') this.setResponseEnabled(id, false);
199
- }
222
+
223
+ // Two slots, both LIVE. An `<adw-alert-response>` is DATA — read for its id, label
224
+ // and appearance and then gone, as GtkBuilder leaves nothing of a `<responses>`
225
+ // entry in the widget tree — so it is consumed rather than re-homed, and
226
+ // `adw_alert_dialog_add_response` is callable at any point in the dialog's life.
227
+ // Everything else is the extra child. `src/slotted-children.ts` has the incident.
228
+ // Installed AFTER the response area exists, because consuming a response fills it.
229
+ bindSlottedChildren(this, [
230
+ {
231
+ claims: (node) => node instanceof Element && node.localName === 'adw-alert-response',
232
+ consume: (node) => this._adoptResponse(node as AdwAlertResponse),
233
+ },
234
+ { into: this._childEl },
235
+ ]).install(this._scrimEl, this._dialogEl);
236
+
237
+ // `_render` runs from `attributeChangedCallback` and from the response API, and an
238
+ // extra child appended after connect triggers neither. AFTER the routing: this
239
+ // derives once synchronously, and a `hidden` child area is not focusable, so
240
+ // deriving it on the not-yet-filled box sent the initial focus to the default
241
+ // response instead of into the content.
242
+ bindEmptySections(this._childEl);
200
243
 
201
244
  // Seed the headless model's text from the parsed markup; the attributes stay the
202
245
  // source of truth for what is rendered.
203
246
  this._model.heading = this.heading;
204
247
  this._model.body = this.body;
205
248
 
249
+ // And the two response IDs. `attributeChangedCallback` returns early until
250
+ // `_initialized`, so an attribute present in the MARKUP — the declarative case
251
+ // these attributes exist for — is never delivered to it. `heading` and `body`
252
+ // survive that only because `_render()` re-reads them from the attribute; these
253
+ // two live in the model and would simply be dropped.
254
+ const closeResponse = this.getAttribute('close-response');
255
+ if (closeResponse !== null) this._model.closeResponse = closeResponse;
256
+ this._model.defaultResponse = this.getAttribute('default-response');
257
+
206
258
  this._render();
207
259
  }
208
260
 
261
+ /**
262
+ * Take one declared `<adw-alert-response>` as a response. An entry with no `id` is
263
+ * skipped rather than given a generated one: the id is what `response` events carry, so
264
+ * inventing one would hand the consumer a name it never wrote.
265
+ */
266
+ private _adoptResponse(el: AdwAlertResponse): void {
267
+ const id = el.getAttribute('id');
268
+ if (!id) return;
269
+ const appearance = (el.getAttribute('appearance') ?? 'default') as AdwResponseAppearance;
270
+ this.addResponse(id, (el.textContent ?? '').trim());
271
+ if (appearance !== 'default') this.setResponseAppearance(id, appearance);
272
+ if (el.getAttribute('enabled') === 'false') this.setResponseEnabled(id, false);
273
+ }
274
+
209
275
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
210
276
  if (!this._initialized) return;
211
277
  if (oldValue === newValue) return;
@@ -220,6 +286,14 @@ export class AdwAlertDialog extends HTMLElement {
220
286
  } else if (name === 'body') {
221
287
  this._model.body = this.body;
222
288
  this.dispatchEvent(new CustomEvent('notify::body', { bubbles: true, detail: { body: this.body } }));
289
+ } else if (name === 'close-response') {
290
+ // The MODEL stays the source of truth for response semantics (see the
291
+ // header) — the attribute is a markup entry point that feeds it, so the
292
+ // setter is not mirrored back and there is no reflection loop. An absent
293
+ // attribute means "leave the model's default", not "reset to `close`".
294
+ if (newValue !== null) this.setCloseResponse(newValue);
295
+ } else if (name === 'default-response') {
296
+ this.setDefaultResponse(newValue);
223
297
  } else if (name === 'open') {
224
298
  this.dispatchEvent(new CustomEvent('notify::open', { bubbles: true, detail: { open: this.open } }));
225
299
  if (this.open) this._modal.present();
@@ -386,11 +460,13 @@ export class AdwAlertDialog extends HTMLElement {
386
460
  this.classList.toggle('open', this.open);
387
461
 
388
462
  const heading = this.heading;
389
- this._headingEl.textContent = heading;
463
+ if (this.headingUseMarkup) this._headingEl.innerHTML = heading;
464
+ else this._headingEl.textContent = heading;
390
465
  this._headingEl.hidden = heading.length === 0;
391
466
 
392
467
  const body = this.body;
393
- this._bodyEl.textContent = body;
468
+ if (this.bodyUseMarkup) this._bodyEl.innerHTML = body;
469
+ else this._bodyEl.textContent = body;
394
470
  this._bodyEl.hidden = body.length === 0;
395
471
 
396
472
  // Horizontal only when prefer-wide-layout is set AND there are at most two
@@ -24,6 +24,7 @@
24
24
  // Copyright (c) 2023-2024 GNOME Foundation Inc. (libadwaita). LGPLv2.1+.
25
25
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
26
26
 
27
+ import { bindSlottedChildren } from '../slotted-children.js';
27
28
  import { AdwModalSurface } from './modal-surface.js';
28
29
 
29
30
  /** Adaptive presentation of the dialog — mirrors Adw.DialogPresentationMode. */
@@ -141,10 +142,6 @@ export class AdwDialog extends HTMLElement {
141
142
 
142
143
  this.classList.add('adw-dialog');
143
144
 
144
- // Snapshot before the subtree is replaced: everything the author placed inside
145
- // becomes the dialog content.
146
- const contentChildren = Array.from(this.childNodes);
147
-
148
145
  this._scrimEl = document.createElement('div');
149
146
  this._scrimEl.className = 'adw-dialog-scrim';
150
147
  this._scrimEl.addEventListener('click', () => this._attemptClose());
@@ -187,11 +184,13 @@ export class AdwDialog extends HTMLElement {
187
184
 
188
185
  this._contentEl = document.createElement('div');
189
186
  this._contentEl.className = 'adw-dialog-content';
190
- for (const child of contentChildren) this._contentEl.appendChild(child);
191
187
 
192
188
  this._boxEl.append(this._headerEl, this._contentEl);
193
189
  this._scrimEl.appendChild(this._boxEl);
194
- this.replaceChildren(this._scrimEl);
190
+ // Everything the author places inside is the dialog content — LIVE, because
191
+ // `Adw.Dialog:child` is a property and a dialog whose body is filled in after it
192
+ // was created is the ordinary case. `src/slotted-children.ts` has the incident.
193
+ bindSlottedChildren(this, [{ into: this._contentEl }]).install(this._scrimEl);
195
194
 
196
195
  this._render();
197
196
  }
@@ -37,6 +37,7 @@ import {
37
37
  type EntryRowRenderState,
38
38
  } from '@gjsify/adwaita-core';
39
39
 
40
+ import { bindSlottedChildren, type AdwSlottedChildren } from '../slotted-children.js';
40
41
  import { type AdwIcon, createAdwIcon } from './adw-icon.js';
41
42
 
42
43
  /**
@@ -71,8 +72,11 @@ export class AdwEntryRow extends HTMLElement {
71
72
  protected _prefixes!: HTMLDivElement;
72
73
  protected _suffixes!: HTMLDivElement;
73
74
  protected _initialized = false;
74
- /** Light-DOM children captured at build time, adopted after `_onConnected`. */
75
- private _authored: Element[] = [];
75
+ /**
76
+ * The two slots, installed after `_onConnected` rather than inside `_build`: the order
77
+ * IS the contract — see the `install` call in `connectedCallback`.
78
+ */
79
+ private _slots!: AdwSlottedChildren;
76
80
  private _lastText = '';
77
81
  private _lastLength = 0;
78
82
 
@@ -159,11 +163,20 @@ export class AdwEntryRow extends HTMLElement {
159
163
  if (this.hasAttribute('text')) this._state.setText(this.getAttribute('text') ?? '');
160
164
  this._renderTitle();
161
165
 
162
- // Subclass parts first, THEN the author's children: `AdwPasswordEntryRow`
163
- // installs its peek toggle from its own `init`, so a consumer
164
- // suffix lands after it and both remain.
166
+ // Subclass parts first, THEN the author's children: `AdwPasswordEntryRow` installs
167
+ // its peek toggle from its own `init`, so a consumer suffix lands after it and both
168
+ // remain. That ordering is why the structure is installed HERE and not at the end of
169
+ // `_build` — `install` routes the author's children on its way, and doing it a phase
170
+ // earlier would put every declared suffix in front of the password row's toggle.
165
171
  this._onConnected();
166
- this._adoptAuthored();
172
+ this._slots.install(
173
+ this._prefixes,
174
+ this._area,
175
+ this._indicator,
176
+ this._applyButton,
177
+ this._editIcon,
178
+ this._suffixes,
179
+ );
167
180
 
168
181
  this._lastText = this._state.text;
169
182
  this._lastLength = this._state.textLength;
@@ -254,12 +267,6 @@ export class AdwEntryRow extends HTMLElement {
254
267
  private _build(): void {
255
268
  const prefix = this._classPrefix;
256
269
 
257
- // Author-supplied light-DOM children are KEPT: `replaceChildren` used to
258
- // discard them silently, so `<adw-entry-row><adw-button>Go</adw-button>`
259
- // rendered without its button. Elements only — GtkBuildable also only
260
- // ever adopts widgets, and keeping stray whitespace would make an empty
261
- // affordance box measure as non-empty.
262
- this._authored = [...this.children];
263
270
  this._prefixes = document.createElement('div');
264
271
  this._prefixes.className = `${prefix}-prefixes`;
265
272
  this._suffixes = document.createElement('div');
@@ -322,34 +329,26 @@ export class AdwEntryRow extends HTMLElement {
322
329
  this._editIcon = createAdwIcon('document-edit', 'adw-row-edit', `${prefix}-edit`);
323
330
  this._editIcon.dataset.iconName = ENTRY_ROW_EDIT_ICON_NAME;
324
331
 
325
- // Snapshot order: indicator → apply → edit icon inside the editable area, with
326
- // the prefixes/suffixes boxes outside it.
327
- this.replaceChildren(
328
- this._prefixes,
329
- this._area,
330
- this._indicator,
331
- this._applyButton,
332
- this._editIcon,
333
- this._suffixes,
334
- );
332
+ // Author-supplied light-DOM children are KEPT and stay LIVE: `replaceChildren` used
333
+ // to discard them silently, so `<adw-entry-row><adw-button>Go</adw-button>` rendered
334
+ // without its button, and the snapshot that fixed that was still a one-shot read — a
335
+ // suffix appended after connect sat outside the affordance box. ELEMENTS only, on
336
+ // both paths: GtkBuildable also only ever adopts widgets, and keeping stray
337
+ // whitespace would make an empty affordance box measure as non-empty. Consumed
338
+ // through the two `add*` methods, because their insertion rules differ —
339
+ // `addPrefix` PREPENDS, mirroring `gtk_box_prepend`. Structure order:
340
+ // indicator → apply → edit icon inside the editable area, prefixes/suffixes outside.
341
+ // `src/slotted-children.ts` has the incident.
342
+ this._slots = bindSlottedChildren(this, [
343
+ { name: 'prefix', consume: (node) => this.addPrefix(node) },
344
+ { name: 'suffix', claims: (node) => node instanceof Element, consume: (node) => this.addSuffix(node) },
345
+ ]);
335
346
  setPartVisible(this._prefixes, false);
336
347
  setPartVisible(this._suffixes, false);
337
348
 
338
349
  this.addEventListener('click', (event) => this._maybeFocus(event));
339
350
  }
340
351
 
341
- /**
342
- * Re-home the children the author wrote. libadwaita's buildable maps an untyped
343
- * `<child>` to a suffix; `slot="prefix"` is the web spelling of `type="prefix"`.
344
- */
345
- private _adoptAuthored(): void {
346
- for (const node of this._authored) {
347
- if (node.getAttribute('slot') === 'prefix') this.addPrefix(node);
348
- else this.addSuffix(node);
349
- }
350
- this._authored = [];
351
- }
352
-
353
352
  /**
354
353
  * `pressed_cb`: a click landing on the row, the editable area, the indicator or
355
354
  * either affordance box grabs focus; anything else (a suffix control, the apply
@@ -22,6 +22,7 @@
22
22
  import { ExpanderState, deriveRowLabels } from '@gjsify/adwaita-core';
23
23
 
24
24
  import { bindEmptySections } from '../empty-sections.js';
25
+ import { bindSlottedChildren } from '../slotted-children.js';
25
26
 
26
27
  // SIDE-EFFECT import, deliberately separate from the type import below: it guarantees
27
28
  // `adw-switch` is defined before this module's `customElements.define` can upgrade a
@@ -105,11 +106,6 @@ export class AdwExpanderRow extends HTMLElement {
105
106
  if (this._initialized) return;
106
107
  this._initialized = true;
107
108
 
108
- const prefixChildren = Array.from(this.querySelectorAll(':scope > [slot="prefix"]'));
109
- const suffixChildren = Array.from(this.querySelectorAll(':scope > [slot="suffix"]'));
110
- const claimed = new Set<Node>([...prefixChildren, ...suffixChildren]);
111
- const rows = Array.from(this.childNodes).filter((node) => !claimed.has(node));
112
-
113
109
  this._headerEl = document.createElement('div');
114
110
  this._headerEl.className = 'adw-expander-row-header';
115
111
 
@@ -130,25 +126,33 @@ export class AdwExpanderRow extends HTMLElement {
130
126
 
131
127
  this._prefixEl = document.createElement('div');
132
128
  this._prefixEl.className = 'adw-expander-row-prefix';
133
- for (const child of prefixChildren) this._prefixEl.appendChild(child);
134
129
 
135
130
  this._suffixEl = document.createElement('div');
136
131
  this._suffixEl.className = 'adw-expander-row-suffix';
137
- for (const child of suffixChildren) this._suffixEl.appendChild(child);
138
-
139
- // Same reason as the action row's: `_render` is attribute-driven, and a widget
140
- // appended into `prefixSection` after connect is a childList change nothing else
141
- // hears. `contentSection` is NOT in here — an empty disclosure is still the
142
- // disclosure, and `.expanded` alone decides whether it shows.
143
- bindEmptySections(this._prefixEl, this._suffixEl);
144
132
 
145
133
  this._headerEl.append(this._prefixEl, textEl, this._suffixEl, this._enableSwitch, this._chevronEl);
146
134
 
147
135
  this._contentEl = document.createElement('div');
148
136
  this._contentEl.className = 'adw-expander-row-content';
149
- for (const row of rows) this._contentEl.appendChild(row);
150
137
 
151
- this.replaceChildren(this._headerEl, this._contentEl);
138
+ // Three LIVE slots: the two header sections by name, and the disclosure rows as the
139
+ // unnamed default — `adw_expander_row_add_row` works at any point in the widget's
140
+ // life, so a row appended after connect must reach the content box rather than sit
141
+ // beside it. `src/slotted-children.ts` has the incident.
142
+ bindSlottedChildren(this, [
143
+ { name: 'prefix', into: this._prefixEl },
144
+ { name: 'suffix', into: this._suffixEl },
145
+ { into: this._contentEl },
146
+ ]).install(this._headerEl, this._contentEl);
147
+
148
+ // Same reason as the action row's: `_render` is attribute-driven, and a widget
149
+ // appended into `prefixSection` after connect is a childList change nothing else
150
+ // hears. `contentSection` is NOT in here — an empty disclosure is still the
151
+ // disclosure, and `.expanded` alone decides whether it shows.
152
+ //
153
+ // AFTER the routing: this derives once synchronously, so on a box the routing has
154
+ // not filled yet it hides a section that a declared child had already earned.
155
+ bindEmptySections(this._prefixEl, this._suffixEl);
152
156
 
153
157
  this._headerEl.addEventListener('click', (event) => {
154
158
  // Clicks on the enable switch toggle expansion-enable, not disclosure.
@@ -16,6 +16,8 @@
16
16
  // Copyright (c) 2025 csm. MIT License.
17
17
  // Modifications: Reimplemented as Web Component for @gjsify/adwaita-web.
18
18
 
19
+ import { bindSlottedChildren } from '../slotted-children.js';
20
+
19
21
  // Registers <adw-window-title>: the derived centre is one, so importing the bar
20
22
  // alone must still define it.
21
23
  import './adw-window-title.js';
@@ -56,35 +58,41 @@ export class AdwHeaderBar extends HTMLElement {
56
58
  if (this._initialized) return;
57
59
  this._initialized = true;
58
60
 
59
- // Capture any pre-existing slotted children before clearing
60
- const startChildren = Array.from(this.querySelectorAll(':scope > [slot="start"]'));
61
- const centerChildren = Array.from(this.querySelectorAll(':scope > [slot="center"]'));
62
- const endChildren = Array.from(this.querySelectorAll(':scope > [slot="end"]'));
63
-
64
- // Start section
65
61
  this._startEl = document.createElement('div');
66
62
  this._startEl.className = 'adw-header-bar-start';
67
- for (const child of startChildren) this._startEl.appendChild(child);
68
63
 
69
- // Center section — a `slot="center"` widget (title-widget) wins over the
70
- // plain `title` text.
71
64
  this._centerEl = document.createElement('div');
72
65
  this._centerEl.className = 'adw-header-bar-center';
73
- if (centerChildren.length > 0) {
74
- for (const child of centerChildren) this._centerEl.appendChild(child);
75
- } else {
76
- this._titleEl = document.createElement('adw-window-title');
77
- this._titleEl.className = 'adw-header-bar-title';
78
- this._centerEl.appendChild(this._titleEl);
79
- }
80
66
 
81
- // End section
82
67
  this._endEl = document.createElement('div');
83
68
  this._endEl.className = 'adw-header-bar-end';
84
- for (const child of endChildren) this._endEl.appendChild(child);
85
69
 
86
- // Replace all children atomically
87
- this.replaceChildren(this._startEl, this._centerEl, this._endEl);
70
+ // All three slots stay LIVE: a button appended with `slot="end"` after connect used
71
+ // to sit outside the end section, and `adw-header-bar.spec.ts` carried the
72
+ // workaround ("appended LAST") as a house rule. `src/slotted-children.ts` has the
73
+ // incident. The `onAdopt` hook keeps the title-widget either/or true afterwards —
74
+ // a centre widget that arrives late replaces the derived title, as setting
75
+ // `Adw.HeaderBar:title-widget` at any point does.
76
+ bindSlottedChildren(
77
+ this,
78
+ [
79
+ { name: 'start', into: this._startEl },
80
+ { name: 'center', into: this._centerEl },
81
+ { name: 'end', into: this._endEl },
82
+ ],
83
+ (_node, slot) => {
84
+ if (slot.name === 'center') this._dropDerivedTitle();
85
+ },
86
+ ).install(this._startEl, this._centerEl, this._endEl);
87
+
88
+ // Derived only when the centre is still free — the same either/or as
89
+ // `Adw.HeaderBar`, which builds an `AdwWindowTitle` exactly while no title-widget
90
+ // was given.
91
+ if (this._centerEl.childElementCount === 0) {
92
+ this._titleEl = document.createElement('adw-window-title');
93
+ this._titleEl.className = 'adw-header-bar-title';
94
+ this._centerEl.appendChild(this._titleEl);
95
+ }
88
96
  this._renderTitle();
89
97
  }
90
98
 
@@ -98,6 +106,17 @@ export class AdwHeaderBar extends HTMLElement {
98
106
  if (this._initialized) this._renderTitle();
99
107
  }
100
108
 
109
+ /**
110
+ * Give the centre up to a title-widget. `adw_header_bar_set_title_widget` destroys the
111
+ * derived `AdwWindowTitle` rather than stacking the two, so the late case has to remove
112
+ * it too — a bar showing both would be a shape neither GTK nor the declared markup can
113
+ * produce.
114
+ */
115
+ private _dropDerivedTitle() {
116
+ this._titleEl?.remove();
117
+ this._titleEl = null;
118
+ }
119
+
101
120
  private _renderTitle() {
102
121
  // A `slot="center"` widget replaced the derived title, so there is
103
122
  // nothing for the attribute to write to — the same either/or as
@@ -36,6 +36,7 @@ import {
36
36
  } from '@gjsify/adwaita-core';
37
37
 
38
38
  import { bindBreakpointSetter } from '../breakpoints.js';
39
+ import { bindSlottedChildren } from '../slotted-children.js';
39
40
 
40
41
  /** Detail of the `navigation-push` / `navigation-pop` delegation event. */
41
42
  export interface NavigationDelegateDetail {
@@ -142,18 +143,24 @@ export class AdwNavigationSplitView extends HTMLElement {
142
143
  if (this._initialized) return;
143
144
  this._initialized = true;
144
145
 
145
- const sidebarChildren = Array.from(this.querySelectorAll(':scope > [slot="sidebar"]'));
146
- const contentChildren = Array.from(this.querySelectorAll(':scope > [slot="content"]'));
147
-
148
146
  this._sidebarEl = document.createElement('div');
149
147
  this._sidebarEl.className = 'adw-nsv-sidebar';
150
- for (const child of sidebarChildren) this._sidebarEl.appendChild(child);
151
148
 
152
149
  this._contentEl = document.createElement('div');
153
150
  this._contentEl.className = 'adw-nsv-content';
154
- for (const child of contentChildren) this._contentEl.appendChild(child);
155
151
 
156
- this.replaceChildren(this._sidebarEl, this._contentEl);
152
+ // Both panes stay LIVE. `adw_navigation_split_view_set_sidebar` is a property
153
+ // setter, callable whenever, so a pane appended after connect has to reach the
154
+ // pane box AND the state that decides the stack — which is what `_mountPane` does
155
+ // on the way. `src/slotted-children.ts` has the incident.
156
+ bindSlottedChildren(
157
+ this,
158
+ [
159
+ { name: 'sidebar', into: this._sidebarEl },
160
+ { name: 'content', into: this._contentEl },
161
+ ],
162
+ (_node, slot) => this._mountPane(slot.name === 'sidebar' ? 'sidebar' : 'content'),
163
+ ).install(this._sidebarEl, this._contentEl);
157
164
 
158
165
  // Parse-time attributes are the CONSTRUCTION properties, as in the overlay view:
159
166
  // applying them as sequential setters emits a transition for a state the markup
@@ -175,14 +182,30 @@ export class AdwNavigationSplitView extends HTMLElement {
175
182
  // Which children EXIST decides the stack, so the panes are mounted into the state
176
183
  // and not merely into the DOM — a lone child stays visible whatever
177
184
  // `show-content` says, which two CSS classes cannot express.
178
- this._state.setSidebar(sidebarChildren.length > 0 ? { tag: this._tagOf(sidebarChildren) } : null);
179
- this._state.setContent(contentChildren.length > 0 ? { tag: this._tagOf(contentChildren) } : null);
185
+ this._mountPane('sidebar');
186
+ this._mountPane('content');
180
187
  this._state.subscribe(() => {
181
188
  this._reflectShowContent();
182
189
  this._syncClasses();
183
190
  });
184
191
  }
185
192
 
193
+ /**
194
+ * Mount whatever a pane box currently HOLDS into the state — `null` while it is empty.
195
+ *
196
+ * Re-mounting an unchanged pane re-emits the plan, because the state compares the page
197
+ * ref by identity. Both subscribers are idempotent reflections (an attribute and a
198
+ * class list), so a second child in the same pane costs a repaint decision and never a
199
+ * navigation event.
200
+ */
201
+ private _mountPane(pane: 'sidebar' | 'content'): void {
202
+ const box = pane === 'sidebar' ? this._sidebarEl : this._contentEl;
203
+ const children = Array.from(box.children);
204
+ const page = children.length > 0 ? { tag: this._tagOf(children) } : null;
205
+ if (pane === 'sidebar') this._state.setSidebar(page);
206
+ else this._state.setContent(page);
207
+ }
208
+
186
209
  /** `Adw.NavigationPage:tag` off the slotted pane, or `null` when untagged. */
187
210
  private _tagOf(children: readonly Element[]): string | null {
188
211
  for (const child of children) {
@@ -26,6 +26,7 @@ import {
26
26
  } from '@gjsify/adwaita-core';
27
27
 
28
28
  import { bindBreakpointSetter } from '../breakpoints.js';
29
+ import { bindSlottedChildren } from '../slotted-children.js';
29
30
 
30
31
  /**
31
32
  * The reveal spring, as a CSS-timed animator: libadwaita animates with a spring
@@ -173,28 +174,26 @@ export class AdwOverlaySplitView extends HTMLElement {
173
174
  if (this._initialized) return;
174
175
  this._initialized = true;
175
176
 
176
- const sidebarChildren = Array.from(this.querySelectorAll('[slot="sidebar"]'));
177
- const contentChildren = Array.from(this.querySelectorAll('[slot="content"]'));
178
- const unslotted = Array.from(this.childNodes).filter(
179
- (n) => !sidebarChildren.includes(n as Element) && !contentChildren.includes(n as Element),
180
- );
181
-
182
- this.replaceChildren();
183
-
184
177
  this._sidebarEl = document.createElement('div');
185
178
  this._sidebarEl.className = 'adw-osv-sidebar';
186
- sidebarChildren.forEach((c) => this._sidebarEl.appendChild(c));
187
179
 
188
180
  this._contentEl = document.createElement('div');
189
181
  this._contentEl.className = 'adw-osv-content';
190
- contentChildren.forEach((c) => this._contentEl.appendChild(c));
191
- unslotted.forEach((c) => this._contentEl.appendChild(c));
192
182
 
193
183
  this._backdropEl = document.createElement('div');
194
184
  this._backdropEl.className = 'adw-osv-backdrop';
195
185
  this._backdropEl.addEventListener('click', () => this.hideSidebar());
196
186
 
197
- this.append(this._sidebarEl, this._contentEl, this._backdropEl);
187
+ // Both panes stay LIVE, and the unnamed slot is the content — the
188
+ // Adw.OverlaySplitView buildable default. `src/slotted-children.ts` has the
189
+ // incident. The routing also stops at the DIRECT children, which the two
190
+ // selectors here did not: `[slot="sidebar"]` matched at any depth, so a nested
191
+ // split view's sidebar was stolen into the outer one's pane.
192
+ bindSlottedChildren(this, [
193
+ { name: 'sidebar', into: this._sidebarEl },
194
+ { name: 'content', into: this._contentEl },
195
+ { into: this._contentEl },
196
+ ]).install(this._sidebarEl, this._contentEl, this._backdropEl);
198
197
 
199
198
  // The attributes present at parse time ARE the construction options —
200
199
  // `Adw.OverlaySplitView` is built with its properties, not built and then
@@ -50,6 +50,7 @@
50
50
 
51
51
  import type { PreferencesSearchResult, SearchPreferencesOptions } from '@gjsify/adwaita-core';
52
52
  import { searchPreferencesDom } from '../preferences-search.js';
53
+ import { bindSlottedChildren } from '../slotted-children.js';
53
54
  import { AdwModalSurface } from './modal-surface.js';
54
55
 
55
56
  const DEFAULT_CONTENT_WIDTH = 640;
@@ -207,12 +208,6 @@ export class AdwPreferencesDialog extends HTMLElement {
207
208
  if (this._initialized) return;
208
209
  this._initialized = true;
209
210
 
210
- // The page ELEMENTS move into the body intact — their title / name / icon-name
211
- // are what a search result subtitle and a view switcher read — and any unwrapped
212
- // children follow them (Adw.PreferencesDialog's buildable default adds a child
213
- // as a page).
214
- const declared = Array.from(this.childNodes);
215
-
216
211
  // Clicking the scrim dismisses the dialog, or raises close-attempt when locked.
217
212
  this._scrimEl = document.createElement('div');
218
213
  this._scrimEl.className = 'adw-preferences-dialog-scrim';
@@ -253,12 +248,16 @@ export class AdwPreferencesDialog extends HTMLElement {
253
248
 
254
249
  this._pagesEl = document.createElement('div');
255
250
  this._pagesEl.className = 'adw-preferences-dialog-pages';
256
- for (const child of declared) this._pagesEl.appendChild(child);
257
251
  this._bodyEl.appendChild(this._pagesEl);
258
252
 
259
253
  this._dialogEl.append(header, this._bodyEl);
260
254
 
261
- this.replaceChildren(this._scrimEl, this._dialogEl);
255
+ // The page ELEMENTS move into the body intact — their title / name / icon-name are
256
+ // what a search result subtitle and a view switcher read — and any unwrapped child
257
+ // follows them (the Adw.PreferencesDialog buildable default adds a child as a
258
+ // page). LIVE, because `adw_preferences_dialog_add` works at any point in the
259
+ // dialog's life. `src/slotted-children.ts` has the incident.
260
+ bindSlottedChildren(this, [{ into: this._pagesEl }]).install(this._scrimEl, this._dialogEl);
262
261
 
263
262
  // The role, `aria-modal`, Escape (Adw.Dialog's shortcut → `maybe_close_cb`), the
264
263
  // Tab trap and the return-focus.