@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.
- package/README.md +27 -0
- package/lib/types/adw-alert-dialog.spec.d.ts +1 -0
- package/lib/types/elements/adw-alert-dialog.d.ts +12 -0
- package/lib/types/elements/adw-entry-row.d.ts +5 -7
- package/lib/types/elements/adw-header-bar.d.ts +7 -0
- package/lib/types/elements/adw-navigation-split-view.d.ts +9 -0
- package/lib/types/elements/adw-preferences-group.d.ts +18 -9
- package/lib/types/slotted-children.d.ts +65 -0
- package/lib/types/slotted-children.spec.d.ts +1 -0
- package/package.json +6 -6
- package/src/adw-alert-dialog.spec.ts +146 -0
- package/src/adw-header-bar.spec.ts +4 -2
- package/src/elements/adw-action-row.ts +8 -6
- package/src/elements/adw-alert-dialog.ts +99 -23
- package/src/elements/adw-dialog.ts +5 -6
- package/src/elements/adw-entry-row.ts +33 -34
- package/src/elements/adw-expander-row.ts +19 -15
- package/src/elements/adw-header-bar.ts +39 -20
- package/src/elements/adw-navigation-split-view.ts +31 -8
- package/src/elements/adw-overlay-split-view.ts +11 -12
- package/src/elements/adw-preferences-dialog.ts +7 -8
- package/src/elements/adw-preferences-group.ts +37 -36
- package/src/elements/adw-status-page.ts +10 -4
- package/src/elements/adw-toast-overlay.ts +6 -4
- package/src/elements/adw-toolbar-view.ts +16 -11
- package/src/slotted-children.spec.ts +227 -0
- package/src/slotted-children.ts +212 -0
- package/src/test.browser.mts +4 -0
|
@@ -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
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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.
|
|
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
|
-
|
|
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.
|
|
124
|
-
this._observeHost();
|
|
134
|
+
this._observeBoxes();
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
disconnectedCallback() {
|
|
128
|
-
// The observer holds this element alive through the
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
158
|
+
private _observeBoxes(): void {
|
|
147
159
|
if (this._observer) return;
|
|
148
|
-
this._observer = new MutationObserver((
|
|
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
|
-
|
|
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
|
|
90
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
+
};
|