@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.
- 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 +19 -4
- 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 +9 -6
- package/src/adw-alert-dialog.spec.ts +146 -0
- package/src/adw-header-bar.spec.ts +13 -6
- package/src/adw-tab-view.spec.ts +71 -0
- 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 +67 -27
- package/src/elements/adw-navigation-split-view.ts +31 -8
- package/src/elements/adw-navigation-view.ts +23 -3
- 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-tab-view.ts +25 -3
- 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
|
@@ -100,8 +100,20 @@ export class AdwNavigationPage extends HTMLElement {
|
|
|
100
100
|
// back-button derivation read the CORE's copy — so a runtime change has to
|
|
101
101
|
// be pushed into it. Without this the back button only ever reflected the
|
|
102
102
|
// `can-pop` value it had at the last stack mutation.
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
//
|
|
104
|
+
// `instanceof` rather than a cast plus `?.`: the two failures the `?.`
|
|
105
|
+
// conflated are different. A MISSING ancestor is one; an ancestor that IS
|
|
106
|
+
// there but is still an ordinary HTMLElement, because its own definition has
|
|
107
|
+
// not upgraded it yet, is the other, and the cast asserted it away. Measured
|
|
108
|
+
// on `dist/test.browser.mjs` against declared markup parsed before the module
|
|
109
|
+
// loaded: `syncPageProperty is not a function`, once per observed attribute
|
|
110
|
+
// per declared page. Dropping the notification for an un-upgraded view loses
|
|
111
|
+
// nothing — such a view has not registered this page, `syncPageProperty`
|
|
112
|
+
// already returns on `!isRegistered`, and `connectedCallback` reads the
|
|
113
|
+
// properties back out of the element through `readPageProps`.
|
|
114
|
+
const view = this.closest('adw-navigation-view');
|
|
115
|
+
if (!(view instanceof AdwNavigationView)) return;
|
|
116
|
+
view.syncPageProperty(this, name, previous);
|
|
105
117
|
}
|
|
106
118
|
|
|
107
119
|
revertAttribute(name: string, value: string | null): void {
|
|
@@ -464,5 +476,13 @@ export class AdwNavigationView extends HTMLElement {
|
|
|
464
476
|
}
|
|
465
477
|
}
|
|
466
478
|
|
|
467
|
-
|
|
479
|
+
// The VIEW first, and the order carries weight: `define` upgrades every matching
|
|
480
|
+
// element already in the document, immediately, so these two calls are a sequence and
|
|
481
|
+
// not a pair. Registering the page first upgraded every declared
|
|
482
|
+
// `<adw-navigation-page>` while its `<adw-navigation-view>` parent was still an
|
|
483
|
+
// ordinary HTMLElement, and `AdwNavigationPage.attributeChangedCallback` reaches for
|
|
484
|
+
// that parent. Parent before child keeps the window shut; the `instanceof` guard in
|
|
485
|
+
// the callback is what holds when something outside this file reopens it.
|
|
486
|
+
// `scripts/check-adwaita-upgrade-order.mjs` holds the order itself.
|
|
468
487
|
customElements.define('adw-navigation-view', AdwNavigationView);
|
|
488
|
+
customElements.define('adw-navigation-page', AdwNavigationPage);
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from '@gjsify/adwaita-core';
|
|
27
27
|
|
|
28
28
|
import { bindBreakpointSetter } from '../breakpoints.js';
|
|
29
|
+
import { bindSlottedChildren } from '../slotted-children.js';
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* The reveal spring, as a CSS-timed animator: libadwaita animates with a spring
|
|
@@ -173,28 +174,26 @@ export class AdwOverlaySplitView extends HTMLElement {
|
|
|
173
174
|
if (this._initialized) return;
|
|
174
175
|
this._initialized = true;
|
|
175
176
|
|
|
176
|
-
const sidebarChildren = Array.from(this.querySelectorAll('[slot="sidebar"]'));
|
|
177
|
-
const contentChildren = Array.from(this.querySelectorAll('[slot="content"]'));
|
|
178
|
-
const unslotted = Array.from(this.childNodes).filter(
|
|
179
|
-
(n) => !sidebarChildren.includes(n as Element) && !contentChildren.includes(n as Element),
|
|
180
|
-
);
|
|
181
|
-
|
|
182
|
-
this.replaceChildren();
|
|
183
|
-
|
|
184
177
|
this._sidebarEl = document.createElement('div');
|
|
185
178
|
this._sidebarEl.className = 'adw-osv-sidebar';
|
|
186
|
-
sidebarChildren.forEach((c) => this._sidebarEl.appendChild(c));
|
|
187
179
|
|
|
188
180
|
this._contentEl = document.createElement('div');
|
|
189
181
|
this._contentEl.className = 'adw-osv-content';
|
|
190
|
-
contentChildren.forEach((c) => this._contentEl.appendChild(c));
|
|
191
|
-
unslotted.forEach((c) => this._contentEl.appendChild(c));
|
|
192
182
|
|
|
193
183
|
this._backdropEl = document.createElement('div');
|
|
194
184
|
this._backdropEl.className = 'adw-osv-backdrop';
|
|
195
185
|
this._backdropEl.addEventListener('click', () => this.hideSidebar());
|
|
196
186
|
|
|
197
|
-
|
|
187
|
+
// Both panes stay LIVE, and the unnamed slot is the content — the
|
|
188
|
+
// Adw.OverlaySplitView buildable default. `src/slotted-children.ts` has the
|
|
189
|
+
// incident. The routing also stops at the DIRECT children, which the two
|
|
190
|
+
// selectors here did not: `[slot="sidebar"]` matched at any depth, so a nested
|
|
191
|
+
// split view's sidebar was stolen into the outer one's pane.
|
|
192
|
+
bindSlottedChildren(this, [
|
|
193
|
+
{ name: 'sidebar', into: this._sidebarEl },
|
|
194
|
+
{ name: 'content', into: this._contentEl },
|
|
195
|
+
{ into: this._contentEl },
|
|
196
|
+
]).install(this._sidebarEl, this._contentEl, this._backdropEl);
|
|
198
197
|
|
|
199
198
|
// The attributes present at parse time ARE the construction options —
|
|
200
199
|
// `Adw.OverlaySplitView` is built with its properties, not built and then
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
|
|
51
51
|
import type { PreferencesSearchResult, SearchPreferencesOptions } from '@gjsify/adwaita-core';
|
|
52
52
|
import { searchPreferencesDom } from '../preferences-search.js';
|
|
53
|
+
import { bindSlottedChildren } from '../slotted-children.js';
|
|
53
54
|
import { AdwModalSurface } from './modal-surface.js';
|
|
54
55
|
|
|
55
56
|
const DEFAULT_CONTENT_WIDTH = 640;
|
|
@@ -207,12 +208,6 @@ export class AdwPreferencesDialog extends HTMLElement {
|
|
|
207
208
|
if (this._initialized) return;
|
|
208
209
|
this._initialized = true;
|
|
209
210
|
|
|
210
|
-
// The page ELEMENTS move into the body intact — their title / name / icon-name
|
|
211
|
-
// are what a search result subtitle and a view switcher read — and any unwrapped
|
|
212
|
-
// children follow them (Adw.PreferencesDialog's buildable default adds a child
|
|
213
|
-
// as a page).
|
|
214
|
-
const declared = Array.from(this.childNodes);
|
|
215
|
-
|
|
216
211
|
// Clicking the scrim dismisses the dialog, or raises close-attempt when locked.
|
|
217
212
|
this._scrimEl = document.createElement('div');
|
|
218
213
|
this._scrimEl.className = 'adw-preferences-dialog-scrim';
|
|
@@ -253,12 +248,16 @@ export class AdwPreferencesDialog extends HTMLElement {
|
|
|
253
248
|
|
|
254
249
|
this._pagesEl = document.createElement('div');
|
|
255
250
|
this._pagesEl.className = 'adw-preferences-dialog-pages';
|
|
256
|
-
for (const child of declared) this._pagesEl.appendChild(child);
|
|
257
251
|
this._bodyEl.appendChild(this._pagesEl);
|
|
258
252
|
|
|
259
253
|
this._dialogEl.append(header, this._bodyEl);
|
|
260
254
|
|
|
261
|
-
|
|
255
|
+
// The page ELEMENTS move into the body intact — their title / name / icon-name are
|
|
256
|
+
// what a search result subtitle and a view switcher read — and any unwrapped child
|
|
257
|
+
// follows them (the Adw.PreferencesDialog buildable default adds a child as a
|
|
258
|
+
// page). LIVE, because `adw_preferences_dialog_add` works at any point in the
|
|
259
|
+
// dialog's life. `src/slotted-children.ts` has the incident.
|
|
260
|
+
bindSlottedChildren(this, [{ into: this._pagesEl }]).install(this._scrimEl, this._dialogEl);
|
|
262
261
|
|
|
263
262
|
// The role, `aria-modal`, Escape (Adw.Dialog's shortcut → `maybe_close_cb`), the
|
|
264
263
|
// Tab trap and the return-focus.
|
|
@@ -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);
|
|
@@ -74,8 +74,21 @@ export class AdwTabPage extends HTMLElement {
|
|
|
74
74
|
attributeChangedCallback(name: string, _old: string | null, value: string | null) {
|
|
75
75
|
// `closest` still finds the view after adoption: the element is moved
|
|
76
76
|
// into the view's page container, not replaced by a wrapper.
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
//
|
|
78
|
+
// `instanceof` rather than a cast plus `?.`, because the two failures the
|
|
79
|
+
// `?.` conflated are different: a MISSING ancestor, and an ancestor that is
|
|
80
|
+
// there but is still an ordinary HTMLElement because its own definition has
|
|
81
|
+
// not upgraded it. The cast asserted the second away, and it is the one that
|
|
82
|
+
// happened — `syncDeclaredPage is not a function`, 19 times on
|
|
83
|
+
// `/getting-started/`, once per declared page.
|
|
84
|
+
//
|
|
85
|
+
// Dropping the notification for an un-upgraded view is correct and not
|
|
86
|
+
// merely quiet: such a view has not adopted this page yet, and
|
|
87
|
+
// `_adoptDeclaredPage` reads every attribute in PAGE_ATTRIBUTES straight off
|
|
88
|
+
// the element when it does, so there is nothing to lose and nothing to defer.
|
|
89
|
+
const view = this.closest('adw-tab-view');
|
|
90
|
+
if (!(view instanceof AdwTabView)) return;
|
|
91
|
+
view.syncDeclaredPage(this, name, value);
|
|
79
92
|
}
|
|
80
93
|
}
|
|
81
94
|
|
|
@@ -797,5 +810,14 @@ export class AdwTabView extends HTMLElement {
|
|
|
797
810
|
}
|
|
798
811
|
}
|
|
799
812
|
|
|
800
|
-
|
|
813
|
+
// The VIEW first, and the order carries weight: `define` upgrades every matching
|
|
814
|
+
// element already in the document, immediately. Registering the page first therefore
|
|
815
|
+
// upgraded every declared `<adw-tab-page>` while its `<adw-tab-view>` parent was still
|
|
816
|
+
// un-upgraded, and `AdwTabPage.attributeChangedCallback` reaches for that parent.
|
|
817
|
+
// Parent before child keeps the window shut; the `instanceof` guard in the callback is
|
|
818
|
+
// what holds when something outside this file reopens it — and because the guard makes
|
|
819
|
+
// the order UNOBSERVABLE (with it in place, page-first renders identically and every
|
|
820
|
+
// test still passes, measured), the order itself is held by
|
|
821
|
+
// `scripts/check-adwaita-upgrade-order.mjs` rather than by a spec.
|
|
801
822
|
customElements.define('adw-tab-view', AdwTabView);
|
|
823
|
+
customElements.define('adw-tab-page', AdwTabPage);
|
|
@@ -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
|
+
};
|