@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.
@@ -24,6 +24,8 @@
24
24
 
25
25
  import { derivePreferencesGroupHeader } from '@gjsify/adwaita-core';
26
26
 
27
+ import { bindSlottedChildren } from '../slotted-children.js';
28
+
27
29
  /** A child that asked to sit in the header rather than in the boxed list. */
28
30
  function isHeaderSuffix(node: Node): boolean {
29
31
  return node instanceof Element && node.getAttribute('slot') === 'header-suffix';
@@ -36,6 +38,12 @@ export class AdwPreferencesGroup extends HTMLElement {
36
38
  private _suffixEl!: HTMLDivElement;
37
39
  private _listboxEl!: HTMLDivElement;
38
40
  private _initialized = false;
41
+ /**
42
+ * Watches the two BOXES, not the host: the routing of a host child is
43
+ * `src/slotted-children.ts`'s job now, and what is left here is the header derivation,
44
+ * whose inputs are the row count and whether a suffix exists. A removal straight out of
45
+ * the listbox changes both and reaches no other callback.
46
+ */
39
47
  private _observer: MutationObserver | null = null;
40
48
 
41
49
  static get observedAttributes() {
@@ -55,10 +63,11 @@ export class AdwPreferencesGroup extends HTMLElement {
55
63
  * `adw_preferences_group_add`: a row goes into the boxed list, a `header-suffix`
56
64
  * child into the header.
57
65
  *
58
- * Exposed as a method AND wired to a `MutationObserver` on the host, because both
59
- * spellings occur — `group.addRow(row)` in code, `group.append(row)` from a
60
- * framework's rendering — and a bare child of the host would sit outside the card,
61
- * invisible to the row count that decides whether the card is painted at all.
66
+ * Kept as a method as well as a slot, because both spellings occur —
67
+ * `group.addRow(row)` in code, `group.append(row)` from a framework's rendering — and a
68
+ * bare child of the host would sit outside the card, invisible to the row count that
69
+ * decides whether the card is painted at all. The `append` spelling is routed by
70
+ * `src/slotted-children.ts`, which every other slotted element now shares.
62
71
  */
63
72
  addRow(child: Node): void {
64
73
  if (isHeaderSuffix(child)) this._suffixEl.appendChild(child);
@@ -81,15 +90,11 @@ export class AdwPreferencesGroup extends HTMLElement {
81
90
 
82
91
  connectedCallback() {
83
92
  if (this._initialized) {
84
- this._observeHost();
93
+ this._observeBoxes();
85
94
  return;
86
95
  }
87
96
  this._initialized = true;
88
97
 
89
- const children = Array.from(this.childNodes);
90
- const suffixChildren = children.filter(isHeaderSuffix);
91
- const rowChildren = children.filter((child) => !suffixChildren.includes(child));
92
-
93
98
  this._headerEl = document.createElement('div');
94
99
  this._headerEl.className = 'adw-preferences-group-header';
95
100
 
@@ -106,26 +111,31 @@ export class AdwPreferencesGroup extends HTMLElement {
106
111
 
107
112
  this._suffixEl = document.createElement('div');
108
113
  this._suffixEl.className = 'adw-preferences-group-suffix';
109
- for (const child of suffixChildren) this._suffixEl.appendChild(child);
110
114
 
111
115
  this._headerEl.append(labels, this._suffixEl);
112
116
 
113
117
  this._listboxEl = document.createElement('div');
114
118
  this._listboxEl.className = 'adw-preferences-group-listbox';
115
- for (const child of rowChildren) this._listboxEl.appendChild(child);
116
119
 
117
- this.replaceChildren(this._headerEl, this._listboxEl);
120
+ bindSlottedChildren(
121
+ this,
122
+ [{ name: 'header-suffix', into: this._suffixEl }, { into: this._listboxEl }],
123
+ // Redundant while this group is connected — the listbox record covers it — and
124
+ // the only thing that covers it while it is DETACHED, where `_observeBoxes` is
125
+ // deliberately off. A row appended to a parked group must be right when it
126
+ // comes back, not one derivation behind.
127
+ () => this._renderHeader(),
128
+ ).install(this._headerEl, this._listboxEl);
118
129
  // adw-preferences-group.c:319 — GTK_ACCESSIBLE_ROLE_GROUP. A GROUP, not a list:
119
130
  // that is why the rows inside carry no `listitem` role, which outside a list
120
131
  // would be worse than none.
121
132
  this.setAttribute('role', 'group');
122
133
 
123
- this._renderHeader();
124
- this._observeHost();
134
+ this._observeBoxes();
125
135
  }
126
136
 
127
137
  disconnectedCallback() {
128
- // The observer holds this element alive through the host node; drop it
138
+ // The observer holds this element alive through the boxes it watches; drop it
129
139
  // with the connection that created it, not at some later "cleanup".
130
140
  this._observer?.disconnect();
131
141
  this._observer = null;
@@ -136,35 +146,26 @@ export class AdwPreferencesGroup extends HTMLElement {
136
146
  }
137
147
 
138
148
  /**
139
- * Adopt children appended to the HOST after the subtree was built, so
140
- * `group.append(row)` behaves like `adw_preferences_group_add`.
149
+ * Re-derive the header whenever the two boxes change, in EITHER direction.
141
150
  *
142
151
  * C gets this for free: `gtk_widget_observe_children` on the listbox drives
143
- * `update_listbox_visibility` through `items-changed`. A DOM element needs the
144
- * observer for the same rule to hold over time rather than only at connect.
152
+ * `update_listbox_visibility` through `items-changed`. A DOM element needs the observer
153
+ * for the same rule to hold over time rather than only at connect. Adoption is not what
154
+ * this watches — a row appended to the host reaches the listbox through the shared slot
155
+ * routing, and lands here as the listbox record it causes. A row REMOVED straight out
156
+ * of the listbox reaches no other callback at all, and it still changes the row count.
145
157
  */
146
- private _observeHost(): void {
158
+ private _observeBoxes(): void {
147
159
  if (this._observer) return;
148
- this._observer = new MutationObserver((records) => {
149
- for (const record of records) {
150
- // Only the HOST's own children need re-homing. Records from the listbox are
151
- // the CONSEQUENCE of that move; re-adopting them would append each row a
152
- // second time and reorder the list.
153
- if (record.target !== this) continue;
154
- for (const node of record.addedNodes) {
155
- if (!(node instanceof Element)) continue;
156
- if (node === this._headerEl || node === this._listboxEl) continue;
157
- this.addRow(node);
158
- }
159
- }
160
- // A removal straight out of the listbox still changes the row count.
161
- this._renderHeader();
162
- });
163
- this._observer.observe(this, { childList: true });
160
+ this._observer = new MutationObserver(() => this._renderHeader());
164
161
  this._observer.observe(this._listboxEl, { childList: true });
165
162
  // The suffix too: `hasHeaderSuffix` is an INPUT to the header derivation, so a
166
163
  // suffix appended after connect left the header hidden on a group with no title.
167
164
  this._observer.observe(this._suffixEl, { childList: true });
165
+ // Arming and deriving are ONE act, as in `src/empty-sections.ts`: a group that was
166
+ // parked, filled and re-attached would otherwise come back showing the header state
167
+ // it had when it left.
168
+ this._renderHeader();
168
169
  }
169
170
 
170
171
  private _renderHeader() {
@@ -10,6 +10,7 @@
10
10
  import { stringIsNotEmpty } from '@gjsify/adwaita-core';
11
11
 
12
12
  import { bindEmptySections } from '../empty-sections.js';
13
+ import { bindSlottedChildren } from '../slotted-children.js';
13
14
  import { type AdwIcon, createAdwIcon } from './adw-icon.js';
14
15
 
15
16
  export class AdwStatusPage extends HTMLElement {
@@ -27,8 +28,6 @@ export class AdwStatusPage extends HTMLElement {
27
28
  if (this._initialized) return;
28
29
  this._initialized = true;
29
30
 
30
- const children = Array.from(this.childNodes);
31
-
32
31
  this._iconEl = createAdwIcon(null, 'adw-status-page-icon');
33
32
 
34
33
  this._titleEl = document.createElement('span');
@@ -39,9 +38,16 @@ export class AdwStatusPage extends HTMLElement {
39
38
 
40
39
  this._childEl = document.createElement('div');
41
40
  this._childEl.className = 'adw-status-page-child';
42
- for (const child of children) this._childEl.appendChild(child);
43
41
 
44
- this.replaceChildren(this._iconEl, this._titleEl, this._descEl, this._childEl);
42
+ // `Adw.StatusPage:child` is a property, so the action a status page offers can be
43
+ // set at any point — a button appended after connect has to land in the child box,
44
+ // not beside it. `src/slotted-children.ts` has the incident.
45
+ bindSlottedChildren(this, [{ into: this._childEl }]).install(
46
+ this._iconEl,
47
+ this._titleEl,
48
+ this._descEl,
49
+ this._childEl,
50
+ );
45
51
  // `_render` only ever runs from `attributeChangedCallback`, which an action
46
52
  // appended into the child slot after connect does not trigger.
47
53
  bindEmptySections(this._childEl);
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { AdwToast, AdwToastQueue } from '@gjsify/adwaita-core';
21
21
 
22
+ import { bindSlottedChildren } from '../slotted-children.js';
22
23
  import { createAdwIcon } from './adw-icon.js';
23
24
  import type { ToastScheduler, ToastTimerHandle } from '@gjsify/adwaita-core';
24
25
 
@@ -86,16 +87,17 @@ export class AdwToastOverlay extends HTMLElement {
86
87
  if (this._initialized) return;
87
88
  this._initialized = true;
88
89
 
89
- // The declared children become the wrapped content (Adw.ToastOverlay:child);
90
- // the toast container is layered over the bottom edge.
90
+ // The children become the wrapped content (Adw.ToastOverlay:child); the toast
91
+ // container is layered over the bottom edge. LIVE, because the overlay is the
92
+ // outermost widget of a window and the app it wraps is very often appended to it
93
+ // after it exists. `src/slotted-children.ts` has the incident.
91
94
  const content = document.createElement('div');
92
95
  content.className = 'adw-toast-overlay-content';
93
- for (const child of Array.from(this.childNodes)) content.appendChild(child);
94
96
 
95
97
  this._toasts = document.createElement('div');
96
98
  this._toasts.className = 'adw-toast-overlay-toasts';
97
99
 
98
- this.replaceChildren(content, this._toasts);
100
+ bindSlottedChildren(this, [{ into: content }]).install(content, this._toasts);
99
101
  }
100
102
 
101
103
  /**
@@ -32,6 +32,7 @@ import {
32
32
  } from '@gjsify/adwaita-core';
33
33
  import { bindEmptySections } from '../empty-sections.js';
34
34
  import { AdwScrollShading } from '../scroll-shading.js';
35
+ import { bindSlottedChildren } from '../slotted-children.js';
35
36
 
36
37
  export class AdwToolbarView extends HTMLElement {
37
38
  private _topEl!: HTMLDivElement;
@@ -71,31 +72,35 @@ export class AdwToolbarView extends HTMLElement {
71
72
  if (!this._initialized) {
72
73
  this._initialized = true;
73
74
 
74
- const topChildren = Array.from(this.querySelectorAll(':scope > [slot="top"]'));
75
- const bottomChildren = Array.from(this.querySelectorAll(':scope > [slot="bottom"]'));
76
- const contentChildren = Array.from(this.childNodes).filter(
77
- (n) => !topChildren.includes(n as Element) && !bottomChildren.includes(n as Element),
78
- );
79
-
80
75
  this._topEl = document.createElement('div');
81
76
  this._topEl.className = 'adw-toolbar-view-top';
82
- for (const child of topChildren) this._topEl.appendChild(child);
83
77
 
84
78
  this._contentEl = document.createElement('div');
85
79
  this._contentEl.className = 'adw-toolbar-view-content';
86
- for (const child of contentChildren) this._contentEl.appendChild(child);
87
80
 
88
81
  this._bottomEl = document.createElement('div');
89
82
  this._bottomEl.className = 'adw-toolbar-view-bottom';
90
- for (const child of bottomChildren) this._bottomEl.appendChild(child);
83
+
84
+ // `topBar` invites an imperative append, and a DECLARED `slot="top"` bar has to
85
+ // reach the same box whenever it is written — including after connect, which a
86
+ // snapshot here cannot see. The unnamed slot is the content, the
87
+ // Adw.ToolbarView buildable default. `src/slotted-children.ts` has the incident.
88
+ bindSlottedChildren(this, [
89
+ { name: 'top', into: this._topEl },
90
+ { name: 'bottom', into: this._bottomEl },
91
+ { into: this._contentEl },
92
+ ]).install(this._topEl, this._contentEl, this._bottomEl);
91
93
 
92
94
  // A bar appended imperatively — which `topBar` invites — is a childList
93
95
  // change, not an attribute one, so the empty/non-empty decision cannot be a
94
96
  // one-shot read here. The ResizeObserver below then sees the bar go 0 → 48
95
97
  // and the undershoot classes follow on their own.
98
+ //
99
+ // AFTER the slot routing, and that order is load-bearing: this derives once
100
+ // SYNCHRONOUSLY, so on empty boxes it hides both and only un-hides them a
101
+ // microtask later — long after `_syncClasses` has measured a declared bar at
102
+ // `offsetHeight` 0 and dropped the undershoot classes for good.
96
103
  bindEmptySections(this._topEl, this._bottomEl);
97
-
98
- this.replaceChildren(this._topEl, this._contentEl, this._bottomEl);
99
104
  }
100
105
 
101
106
  // `update_undershoots` runs from size_allocate, so the classes have to
@@ -0,0 +1,227 @@
1
+ // Slot adoption, driven in both directions — declared at parse time and appended after
2
+ // connect — and required to reach the SAME place.
3
+ //
4
+ // The invariant this file exists for is an EQUALITY, not a presence: "the child is
5
+ // somewhere in the subtree" was true of the bug too. Before `src/slotted-children.ts` a
6
+ // `slot="suffix"` control appended after connect stayed a direct child of the host,
7
+ // beside the internal sections rather than inside one — still in the subtree, still
8
+ // findable by `querySelector`, and laid out somewhere else entirely. So every assertion
9
+ // below compares the declared placement against the appended one, box path AND index.
10
+ //
11
+ // The second half is DERIVED, for the reason `connect-lifecycle.spec.ts` gives about its
12
+ // own driver: a per-widget slot spec is one more spec per widget to forget. It reads the
13
+ // bindings back off the registered elements (`slottedChildrenOf`), so an element that
14
+ // gains a slot enrols itself, and one that stops binding drops out of the driver visibly
15
+ // instead of silently.
16
+ import { describe, expect, it } from '@gjsify/unit';
17
+
18
+ import * as adwaitaWeb from './index.js';
19
+ import { bindSlottedChildren, slottedChildrenOf } from './slotted-children.js';
20
+
21
+ /** One microtask checkpoint — where a MutationObserver callback lands. */
22
+ const settle = () => Promise.resolve();
23
+
24
+ /** A host mounted in the document, since adoption is only interesting where a widget is. */
25
+ function mounted<T extends HTMLElement>(el: T): T {
26
+ document.body.appendChild(el);
27
+ return el;
28
+ }
29
+
30
+ /**
31
+ * WHERE inside `host` a node sits: the chain of boxes down to its parent, plus its index
32
+ * among that parent's child nodes.
33
+ *
34
+ * The index is half the point. Two children that both reached the right box in the wrong
35
+ * order share a path, and a driver that stopped at the path would call that agreement.
36
+ */
37
+ function placement(host: Element, node: Node): string {
38
+ if (node.parentNode === null) return 'detached';
39
+ if (!host.contains(node)) return 'outside';
40
+ const steps: string[] = [];
41
+ for (let el = node.parentElement; el !== null && el !== host; el = el.parentElement) {
42
+ const classes = [...el.classList].sort().join('.');
43
+ steps.unshift(classes === '' ? el.localName : `${el.localName}.${classes}`);
44
+ }
45
+ const index = Array.from(node.parentNode.childNodes).indexOf(node as ChildNode);
46
+ return `${steps.join(' > ')}[${index}]`;
47
+ }
48
+
49
+ /** A probe carrying a slot name, or none at all for the default slot. */
50
+ function probe(slot: string | undefined, id: string): HTMLElement {
51
+ const el = document.createElement('div');
52
+ if (slot !== undefined) el.setAttribute('slot', slot);
53
+ el.dataset.probe = id;
54
+ return el;
55
+ }
56
+
57
+ export const AdwSlottedChildrenTest = async () => {
58
+ await describe('bindSlottedChildren', async () => {
59
+ await it('routes the children it was installed over, in document order', async () => {
60
+ const host = mounted(document.createElement('div'));
61
+ const first = probe('side', 'a');
62
+ const second = probe(undefined, 'b');
63
+ const third = probe('side', 'c');
64
+ host.append(first, second, third);
65
+
66
+ const side = document.createElement('div');
67
+ side.className = 'side';
68
+ const body = document.createElement('div');
69
+ body.className = 'body';
70
+ bindSlottedChildren(host, [{ name: 'side', into: side }, { into: body }]).install(side, body);
71
+
72
+ const report = [first, second, third].map((el) => placement(host, el)).join(' | ');
73
+ host.remove();
74
+ expect(report).toBe('div.side[0] | div.body[0] | div.side[1]');
75
+ });
76
+
77
+ await it('routes a child APPENDED after install to the same box, at the next index', async () => {
78
+ const host = mounted(document.createElement('div'));
79
+ const declared = probe('side', 'a');
80
+ host.append(declared);
81
+
82
+ const side = document.createElement('div');
83
+ side.className = 'side';
84
+ bindSlottedChildren(host, [{ name: 'side', into: side }]).install(side);
85
+
86
+ const late = probe('side', 'b');
87
+ host.append(late);
88
+ await settle();
89
+
90
+ const report = `${placement(host, declared)} | ${placement(host, late)}`;
91
+ host.remove();
92
+ expect(report).toBe('div.side[0] | div.side[1]');
93
+ });
94
+
95
+ await it('leaves a child whose slot name nothing declares where the author put it', async () => {
96
+ const host = mounted(document.createElement('div'));
97
+ const side = document.createElement('div');
98
+ side.className = 'side';
99
+ // No default slot: with one, an unmatched NAME would silently become default
100
+ // content and render in the wrong box looking deliberate. The native assignment
101
+ // algorithm assigns it nowhere, and so does this.
102
+ bindSlottedChildren(host, [{ name: 'side', into: side }]).install(side);
103
+
104
+ const typo = probe('sied', 'a');
105
+ host.append(typo);
106
+ await settle();
107
+
108
+ const report = placement(host, typo);
109
+ host.remove();
110
+ expect(report).toBe('[1]');
111
+ });
112
+
113
+ await it('re-adopts a child that was appended, removed and appended again', async () => {
114
+ const host = mounted(document.createElement('div'));
115
+ const side = document.createElement('div');
116
+ side.className = 'side';
117
+ bindSlottedChildren(host, [{ name: 'side', into: side }]).install(side);
118
+
119
+ const child = probe('side', 'a');
120
+ host.append(child);
121
+ await settle();
122
+ const first = `${placement(host, child)} n=${side.childElementCount}`;
123
+
124
+ child.remove();
125
+ await settle();
126
+ const gone = `${placement(host, child)} n=${side.childElementCount}`;
127
+
128
+ host.append(child);
129
+ await settle();
130
+ // n=1, not n=2: the node is MOVED back rather than copied, and the removal left
131
+ // no placeholder behind for it to be adopted alongside.
132
+ const again = `${placement(host, child)} n=${side.childElementCount}`;
133
+
134
+ host.remove();
135
+ expect(`${first} / ${gone} / ${again}`).toBe('div.side[0] n=1 / detached n=0 / div.side[0] n=1');
136
+ });
137
+
138
+ await it('hands a consumed child to the call and leaves nothing of it in the host', async () => {
139
+ const host = mounted(document.createElement('div'));
140
+ const taken: string[] = [];
141
+ const body = document.createElement('div');
142
+ body.className = 'body';
143
+ bindSlottedChildren(host, [
144
+ {
145
+ claims: (node) => node instanceof Element && node.localName === 'meta',
146
+ consume: (node) => taken.push((node as Element).getAttribute('name') ?? ''),
147
+ },
148
+ { into: body },
149
+ ]).install(body);
150
+
151
+ const data = document.createElement('meta');
152
+ data.setAttribute('name', 'late');
153
+ host.append(data);
154
+ await settle();
155
+
156
+ const report = `${taken.join(',')} | ${placement(host, data)} | body=${body.childElementCount}`;
157
+ host.remove();
158
+ expect(report).toBe('late | detached | body=0');
159
+ });
160
+ });
161
+
162
+ // Every registered element that binds a slot, both ways round. This is the driver the
163
+ // fix exists for: an element is asked for its own slots and then required to put a
164
+ // declared child and an appended one in the same place.
165
+ await describe('every slotted element adopts a late child where it adopts a declared one', async () => {
166
+ const tags: string[] = [];
167
+ for (const exported of Object.values(adwaitaWeb)) {
168
+ if (typeof exported !== 'function' || !(exported.prototype instanceof HTMLElement)) continue;
169
+ const tag = customElements.getName(exported as CustomElementConstructor);
170
+ if (tag !== null) tags.push(tag);
171
+ }
172
+ tags.sort();
173
+
174
+ /** The distinct slot names one element declares — `undefined` is its default slot. */
175
+ function slotNames(tag: string): Array<string | undefined> {
176
+ const el = mounted(document.createElement(tag));
177
+ const binding = slottedChildrenOf(el);
178
+ el.remove();
179
+ if (binding === undefined) return [];
180
+ const names = new Set<string | undefined>();
181
+ for (const slot of binding.slots) names.add(slot.name);
182
+ return [...names];
183
+ }
184
+
185
+ const slotted = tags.map((tag) => [tag, slotNames(tag)] as const).filter(([, names]) => names.length > 0);
186
+
187
+ await it('finds the elements that declare slots', () => {
188
+ // A driver that scans nothing passes vacuously and reports a green it never
189
+ // earned. A FLOOR, not an exact count: the point of deriving the list is that it
190
+ // grows without this file being edited.
191
+ expect(`slotted elements: ${slotted.length > 8}`).toBe('slotted elements: true');
192
+ });
193
+
194
+ for (const [tag, names] of slotted) {
195
+ for (const name of names) {
196
+ const label = name ?? '(default)';
197
+ await it(`<${tag}> slot="${label}"`, async () => {
198
+ // TWO children per slot, so the comparison pins ORDER and not only the box.
199
+ const declaredHost = document.createElement(tag);
200
+ const declaredA = probe(name, 'a');
201
+ const declaredB = probe(name, 'b');
202
+ declaredHost.append(declaredA, declaredB);
203
+ mounted(declaredHost);
204
+
205
+ const lateHost = mounted(document.createElement(tag));
206
+ const lateA = probe(name, 'a');
207
+ const lateB = probe(name, 'b');
208
+ lateHost.append(lateA);
209
+ lateHost.append(lateB);
210
+ await settle();
211
+
212
+ const declared = [declaredA, declaredB].map((el) => placement(declaredHost, el)).join(' | ');
213
+ const late = [lateA, lateB].map((el) => placement(lateHost, el)).join(' | ');
214
+ // Measure, THEN tear down, THEN assert — the reason
215
+ // `connect-lifecycle.spec.ts` gives: a host left on the page pushes every
216
+ // later one down and turns one failure into a cascade naming the wrong
217
+ // widget.
218
+ declaredHost.remove();
219
+ lateHost.remove();
220
+ expect(`<${tag}> slot=${label} appended: ${late}`).toBe(
221
+ `<${tag}> slot=${label} appended: ${declared}`,
222
+ );
223
+ });
224
+ }
225
+ }
226
+ });
227
+ };