@gjsify/adwaita-web 0.43.0 → 0.45.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.
@@ -1,8 +1,20 @@
1
1
  // <adw-header-bar> — Adwaita header bar with centered title and start/end button slots.
2
2
  //
3
- // The derived centre is an `<adw-window-title>`, which is what `Adw.HeaderBar`
4
- // itself puts there (`adw-header-bar.c`: the title widget it creates when none is
5
- // given IS an `AdwWindowTitle`). It used to be a bare span with
3
+ // The derived centre is an `<adw-window-title>`. That is a DIVERGENCE, and the comment
4
+ // here used to claim the opposite — that `Adw.HeaderBar` puts one there. Measured
5
+ // against the source: `construct_title_label` (adw-header-bar.c:512) builds a plain
6
+ // `gtk_label_new (NULL)` with the `title` class, `single-line-mode` and
7
+ // `ellipsize=END`, and `Adw.HeaderBar` has NO `title` property at all — `update_title`
8
+ // (:475) resolves one from the navigation page, then the dialog, then the root window,
9
+ // then `g_get_application_name`, then `g_get_prgname`. An app that wants a subtitle
10
+ // there sets an `AdwWindowTitle` as its title-widget.
11
+ //
12
+ // The divergence is kept: a declarative surface wants `title=` / `subtitle=` attributes
13
+ // rather than a widget handoff, and both renderers already use them. It is recorded as
14
+ // `HeaderBarRenderState.derivedSubtitle` in `@gjsify/adwaita-core`, so it is a named
15
+ // difference instead of a mistaken claim of fidelity.
16
+ //
17
+ // It used to be a bare span with
6
18
  // `textContent = title ?? ''`, and that span carried none of the three rules the
7
19
  // window title already held in `@gjsify/adwaita-core`: an EMPTY title still
8
20
  // reserved a blank line, re-setting the same value repainted, and there was no
@@ -16,6 +28,8 @@
16
28
  // Copyright (c) 2025 csm. MIT License.
17
29
  // Modifications: Reimplemented as Web Component for @gjsify/adwaita-web.
18
30
 
31
+ import { bindSlottedChildren } from '../slotted-children.js';
32
+
19
33
  // Registers <adw-window-title>: the derived centre is one, so importing the bar
20
34
  // alone must still define it.
21
35
  import './adw-window-title.js';
@@ -27,8 +41,10 @@ export class AdwHeaderBar extends HTMLElement {
27
41
  private _endEl: HTMLDivElement | null = null;
28
42
  /**
29
43
  * The derived `<adw-window-title>`, or `null` when a `slot="center"`
30
- * title-widget took the centre. `Adw.HeaderBar` has the same either/or:
31
- * setting `title-widget` replaces the derived `AdwWindowTitle` outright.
44
+ * title-widget took the centre. `Adw.HeaderBar` has the same either/or —
45
+ * `adw_header_bar_set_title_widget` empties the centre bin before installing
46
+ * (adw-header-bar.c:1201) — over a derived `GtkLabel`, not an `AdwWindowTitle`;
47
+ * see the divergence at the top of this file.
32
48
  */
33
49
  private _titleEl: HTMLElement | null = null;
34
50
 
@@ -56,48 +72,72 @@ export class AdwHeaderBar extends HTMLElement {
56
72
  if (this._initialized) return;
57
73
  this._initialized = true;
58
74
 
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
75
  this._startEl = document.createElement('div');
66
76
  this._startEl.className = 'adw-header-bar-start';
67
- for (const child of startChildren) this._startEl.appendChild(child);
68
77
 
69
- // Center section — a `slot="center"` widget (title-widget) wins over the
70
- // plain `title` text.
71
78
  this._centerEl = document.createElement('div');
72
79
  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
80
 
81
- // End section
82
81
  this._endEl = document.createElement('div');
83
82
  this._endEl.className = 'adw-header-bar-end';
84
- for (const child of endChildren) this._endEl.appendChild(child);
85
83
 
86
- // Replace all children atomically
87
- this.replaceChildren(this._startEl, this._centerEl, this._endEl);
84
+ // All three slots stay LIVE: a button appended with `slot="end"` after connect used
85
+ // to sit outside the end section, and `adw-header-bar.spec.ts` carried the
86
+ // workaround ("appended LAST") as a house rule. `src/slotted-children.ts` has the
87
+ // incident. The `onAdopt` hook keeps the title-widget either/or true afterwards —
88
+ // a centre widget that arrives late replaces the derived title, as setting
89
+ // `Adw.HeaderBar:title-widget` at any point does.
90
+ bindSlottedChildren(
91
+ this,
92
+ [
93
+ { name: 'start', into: this._startEl },
94
+ { name: 'center', into: this._centerEl },
95
+ { name: 'end', into: this._endEl },
96
+ ],
97
+ (_node, slot) => {
98
+ if (slot.name === 'center') this._dropDerivedTitle();
99
+ },
100
+ ).install(this._startEl, this._centerEl, this._endEl);
101
+
102
+ // Derived only when the centre is still free — the same either/or as
103
+ // `Adw.HeaderBar`, which runs `construct_title_label` exactly while no
104
+ // title-widget holds the centre (adw-header-bar.c:1211). What it builds there is
105
+ // a `GtkLabel`; this is the documented `<adw-window-title>` divergence.
106
+ if (this._centerEl.childElementCount === 0) {
107
+ this._titleEl = document.createElement('adw-window-title');
108
+ this._titleEl.className = 'adw-header-bar-title';
109
+ this._centerEl.appendChild(this._titleEl);
110
+ }
88
111
  this._renderTitle();
89
112
  }
90
113
 
91
114
  /**
92
115
  * `title` used to be read ONCE, in `connectedCallback`, so every later write
93
116
  * was a silent no-op — a header bar whose title tracked the open document
94
- * kept whatever it was created with. `Adw.HeaderBar`'s derived title widget
95
- * is bound to the property and re-renders on every change.
117
+ * kept whatever it was created with. `Adw.HeaderBar` never reads its title once
118
+ * either: it has no `title` property at all, and re-runs `update_title`
119
+ * (adw-header-bar.c:475) on every ancestor change that could move the answer.
96
120
  */
97
121
  attributeChangedCallback() {
98
122
  if (this._initialized) this._renderTitle();
99
123
  }
100
124
 
125
+ /**
126
+ * Give the centre up to a title-widget. `adw_header_bar_set_title_widget` empties the
127
+ * centre bin and drops its derived label (adw-header-bar.c:1201, :1209) rather than
128
+ * stacking the two, so the late case has to remove ours too — a bar showing both would
129
+ * be a shape neither GTK nor the declared markup can produce.
130
+ *
131
+ * NOT SYMMETRIC HERE, AND KNOWN: C rebuilds the label the moment the title-widget goes
132
+ * away (:1211); nothing in this element brings the derived centre back, so a bar that
133
+ * gave it up has given it up for good. `HeaderBarState` in `@gjsify/adwaita-core` holds
134
+ * the rebuild, and this element adopting it is the rewiring tracked in #1343.
135
+ */
136
+ private _dropDerivedTitle() {
137
+ this._titleEl?.remove();
138
+ this._titleEl = null;
139
+ }
140
+
101
141
  private _renderTitle() {
102
142
  // A `slot="center"` widget replaced the derived title, so there is
103
143
  // 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) {