@gjsify/adwaita-web 0.42.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.
Files changed (32) hide show
  1. package/README.md +27 -0
  2. package/lib/types/adw-alert-dialog.spec.d.ts +1 -0
  3. package/lib/types/elements/adw-alert-dialog.d.ts +12 -0
  4. package/lib/types/elements/adw-carousel.d.ts +8 -0
  5. package/lib/types/elements/adw-entry-row.d.ts +5 -7
  6. package/lib/types/elements/adw-header-bar.d.ts +7 -0
  7. package/lib/types/elements/adw-navigation-split-view.d.ts +9 -0
  8. package/lib/types/elements/adw-preferences-group.d.ts +18 -9
  9. package/lib/types/elements/swipe-drag.d.ts +63 -0
  10. package/lib/types/slotted-children.d.ts +65 -0
  11. package/lib/types/slotted-children.spec.d.ts +1 -0
  12. package/package.json +6 -6
  13. package/src/adw-alert-dialog.spec.ts +146 -0
  14. package/src/adw-header-bar.spec.ts +4 -2
  15. package/src/elements/adw-action-row.ts +8 -6
  16. package/src/elements/adw-alert-dialog.ts +99 -23
  17. package/src/elements/adw-carousel.ts +86 -5
  18. package/src/elements/adw-dialog.ts +5 -6
  19. package/src/elements/adw-entry-row.ts +33 -34
  20. package/src/elements/adw-expander-row.ts +19 -15
  21. package/src/elements/adw-header-bar.ts +39 -20
  22. package/src/elements/adw-navigation-split-view.ts +31 -8
  23. package/src/elements/adw-overlay-split-view.ts +11 -12
  24. package/src/elements/adw-preferences-dialog.ts +7 -8
  25. package/src/elements/adw-preferences-group.ts +37 -36
  26. package/src/elements/adw-status-page.ts +10 -4
  27. package/src/elements/adw-toast-overlay.ts +6 -4
  28. package/src/elements/adw-toolbar-view.ts +16 -11
  29. package/src/elements/swipe-drag.ts +450 -0
  30. package/src/slotted-children.spec.ts +227 -0
  31. package/src/slotted-children.ts +212 -0
  32. package/src/test.browser.mts +4 -0
@@ -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
@@ -9,9 +9,14 @@
9
9
  // position compensation. This element is the DOM half only — scroll-snap strip,
10
10
  // offset↔position, DOM events.
11
11
  //
12
- // The three attribute defaults HTML cannot spell: `allow-scroll-wheel` and
13
- // `interactive` default TRUE, so `="false"` is the off switch; `allow-long-swipes`
14
- // is an ordinary presence attribute. `position` is the INITIAL page only —
12
+ // The four attribute defaults HTML cannot spell: `allow-scroll-wheel`,
13
+ // `allow-mouse-drag` and `interactive` default TRUE, so `="false"` is the off switch;
14
+ // `allow-long-swipes` is an ordinary presence attribute.
15
+ //
16
+ // A MOUSE DRAG is `elements/swipe-drag.ts`, and it is the one gesture a browser gives
17
+ // nothing for: touch and touchpad scroll this strip natively, a held mouse button does
18
+ // not. Upstream turns it on in `init` (:1209) and exposes `allow-mouse-drag`, default
19
+ // TRUE (:1091). Measured before: a 300 px drag moved `position` 0 → 0. `position` is the INITIAL page only —
15
20
  // write-only, with the live value on the `position` property and `notify::position`.
16
21
  //
17
22
  // `page-changed` (`detail = { index }`) fires on every settle, including back
@@ -27,9 +32,11 @@
27
32
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
28
33
  // Modifications: Implemented as Web Components for @gjsify/adwaita-web.
29
34
 
30
- import { CarouselState, carouselPageAllocation } from '@gjsify/adwaita-core';
35
+ import { CarouselState, carouselClosestSnapPoint, carouselPageAllocation } from '@gjsify/adwaita-core';
31
36
  import type { CarouselScrollRequest, CarouselScrollSource, CarouselStateChange } from '@gjsify/adwaita-core';
32
37
 
38
+ import { attachSwipeDrag, type AdwSwipeDrag } from './swipe-drag.js';
39
+
33
40
  /** Common surface an indicator binds to. The concrete element is AdwCarousel. */
34
41
  interface CarouselHost extends HTMLElement {
35
42
  readonly nPages: number;
@@ -94,9 +101,11 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
94
101
  * index while the target page stays the target.
95
102
  */
96
103
  private _scrollTargetId: string | null = null;
104
+ /** The pointer-drag gesture, asked whether it owns the strip. See `_onWheel`. */
105
+ private _drag: AdwSwipeDrag | undefined;
97
106
 
98
107
  static get observedAttributes() {
99
- return ['allow-scroll-wheel', 'allow-long-swipes', 'interactive', 'spacing', 'position'];
108
+ return ['allow-scroll-wheel', 'allow-mouse-drag', 'allow-long-swipes', 'interactive', 'spacing', 'position'];
100
109
  }
101
110
 
102
111
  constructor() {
@@ -132,6 +141,18 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
132
141
  return this._state.pageAt(this._state.position);
133
142
  }
134
143
 
144
+ /**
145
+ * `AdwCarousel:allow-mouse-drag` — whether the strip can be dragged with the pointer.
146
+ * Defaults to TRUE, as in libadwaita, where FALSE leaves dragging to touch only.
147
+ */
148
+ get allowMouseDrag(): boolean {
149
+ return this._state.allowMouseDrag;
150
+ }
151
+
152
+ set allowMouseDrag(value: boolean) {
153
+ this.setAttribute('allow-mouse-drag', value ? 'true' : 'false');
154
+ }
155
+
135
156
  /** Whether the scroll wheel navigates between pages. Defaults to TRUE, as in libadwaita. */
136
157
  get allowScrollWheel(): boolean {
137
158
  return this._state.allowScrollWheel;
@@ -187,6 +208,7 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
187
208
  // Declared properties BEFORE children: `spacing` is part of the page pitch,
188
209
  // so a page added first would be measured against the wrong distance.
189
210
  this._applyAttribute('allow-scroll-wheel', this.getAttribute('allow-scroll-wheel'));
211
+ this._applyAttribute('allow-mouse-drag', this.getAttribute('allow-mouse-drag'));
190
212
  this._applyAttribute('allow-long-swipes', this.getAttribute('allow-long-swipes'));
191
213
  this._applyAttribute('interactive', this.getAttribute('interactive'));
192
214
  this._applyAttribute('spacing', this.getAttribute('spacing'));
@@ -200,6 +222,52 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
200
222
  this._trackEl.addEventListener('scroll', this._onScroll, { passive: true });
201
223
  this._trackEl.addEventListener('wheel', this._onWheel, { passive: false });
202
224
 
225
+ this._drag = attachSwipeDrag({
226
+ // On the TRACK, not on the host: the track is the scroll container the drag
227
+ // writes `scrollLeft` on, and it is what the pages live in.
228
+ host: this._trackEl,
229
+ orientation: 'horizontal',
230
+ // `interactive` gates it because that is exactly what it does upstream —
231
+ // `adw_carousel_set_interactive` is `adw_swipe_tracker_set_enabled` (:1705).
232
+ enabled: () => this._state.interactive && this._state.allowMouseDrag,
233
+ pitch: () => this._distance(),
234
+ points: () => this._state.snapPoints,
235
+ progress: () => this._state.position,
236
+ allowLongSwipes: () => this._state.allowLongSwipes,
237
+ // `adw_carousel_get_cancel_progress` is `get_closest_snap_point`
238
+ // (adw-carousel.c:1294-1299) — the page nearest where the gesture was
239
+ // interrupted, NOT the one it started from.
240
+ //
241
+ // `carouselClosestSnapPoint` and NOT `_state.pageAt`, for two reasons that
242
+ // both make the answer a different page. `pageAt` searches the VISIBLE snap
243
+ // points (it filters out children being removed) and returns an index into
244
+ // that filtered array, so subscripting the unfiltered list with it shifts by
245
+ // one for every removing child ahead of the target. And it is the
246
+ // `count_removing = FALSE` variant, while `get_closest_snap_point` passes
247
+ // TRUE (adw-carousel.c:1298) — this is the one call site that counts them.
248
+ //
249
+ // The POINT, not an index: a page mid-reveal has a fractional size, so
250
+ // `carouselSnapPoints` (cumulative size − 1) hands back non-integers.
251
+ cancelProgress: () => {
252
+ const points = this._state.snapPoints;
253
+ return points[carouselClosestSnapPoint(this._state.position, points)] ?? this._state.position;
254
+ },
255
+ // The DRAG writes the offset directly and the existing scroll handler reads
256
+ // the position back off it — the same path a touch scroll takes, so there is
257
+ // no second route into `position` to drift from this one.
258
+ onUpdate: (progress) => {
259
+ const distance = this._distance();
260
+ if (distance > 0) this._trackEl.scrollLeft = progress * distance;
261
+ },
262
+ // `scroll_to` with the swipe's velocity is what `end_swipe_cb` does (:434).
263
+ // Here the settle is the browser's smooth scroll, so the velocity is unused
264
+ // — snap is already restored, and `to` is a snap point by construction.
265
+ onEnd: (progress) => {
266
+ const distance = this._distance();
267
+ if (distance > 0) this._trackEl.scrollTo({ left: progress * distance });
268
+ },
269
+ });
270
+
203
271
  // Applied last, once the pages it names exist. Custom-element upgrade
204
272
  // delivers attributes BEFORE `connectedCallback`, so reading it here is the
205
273
  // only way `<adw-carousel position="2">` works at all.
@@ -246,6 +314,10 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
246
314
  // usual "presence = on" spelling would invert it.
247
315
  this._state.setAllowScrollWheel(value !== 'false');
248
316
  return;
317
+ case 'allow-mouse-drag':
318
+ // Absent means TRUE, like `allow-scroll-wheel` and for the same reason.
319
+ this._state.setAllowMouseDrag(value !== 'false');
320
+ return;
249
321
  case 'allow-long-swipes':
250
322
  this._state.setAllowLongSwipes(value !== null && value !== 'false');
251
323
  this._applySnapStop();
@@ -546,6 +618,15 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
546
618
  };
547
619
 
548
620
  private _onWheel = (event: WheelEvent): void => {
621
+ // A drag owns the offset while it is claimed. Without this a single wheel notch
622
+ // mid-drag ran the whole paging path and moved `scrollLeft` a full page in one
623
+ // frame — measured 80 → 440 — which the next pointer move then yanked back, and
624
+ // it left `_scrollTargetId` pointing at a page the drag overrode. `GDK_EVENT_STOP`
625
+ // rather than propagate, because the gesture IS handling this widget's scrolling.
626
+ if (this._drag?.dragging === true) {
627
+ event.preventDefault();
628
+ return;
629
+ }
549
630
  const step = this._state.handleWheel({
550
631
  deltaX: event.deltaX,
551
632
  deltaY: event.deltaY,
@@ -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