@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
@@ -36,6 +36,7 @@ import {
36
36
  } from '@gjsify/adwaita-core';
37
37
 
38
38
  import { bindBreakpointSetter } from '../breakpoints.js';
39
+ import { bindSlottedChildren } from '../slotted-children.js';
39
40
 
40
41
  /** Detail of the `navigation-push` / `navigation-pop` delegation event. */
41
42
  export interface NavigationDelegateDetail {
@@ -142,18 +143,24 @@ export class AdwNavigationSplitView extends HTMLElement {
142
143
  if (this._initialized) return;
143
144
  this._initialized = true;
144
145
 
145
- const sidebarChildren = Array.from(this.querySelectorAll(':scope > [slot="sidebar"]'));
146
- const contentChildren = Array.from(this.querySelectorAll(':scope > [slot="content"]'));
147
-
148
146
  this._sidebarEl = document.createElement('div');
149
147
  this._sidebarEl.className = 'adw-nsv-sidebar';
150
- for (const child of sidebarChildren) this._sidebarEl.appendChild(child);
151
148
 
152
149
  this._contentEl = document.createElement('div');
153
150
  this._contentEl.className = 'adw-nsv-content';
154
- for (const child of contentChildren) this._contentEl.appendChild(child);
155
151
 
156
- this.replaceChildren(this._sidebarEl, this._contentEl);
152
+ // Both panes stay LIVE. `adw_navigation_split_view_set_sidebar` is a property
153
+ // setter, callable whenever, so a pane appended after connect has to reach the
154
+ // pane box AND the state that decides the stack — which is what `_mountPane` does
155
+ // on the way. `src/slotted-children.ts` has the incident.
156
+ bindSlottedChildren(
157
+ this,
158
+ [
159
+ { name: 'sidebar', into: this._sidebarEl },
160
+ { name: 'content', into: this._contentEl },
161
+ ],
162
+ (_node, slot) => this._mountPane(slot.name === 'sidebar' ? 'sidebar' : 'content'),
163
+ ).install(this._sidebarEl, this._contentEl);
157
164
 
158
165
  // Parse-time attributes are the CONSTRUCTION properties, as in the overlay view:
159
166
  // applying them as sequential setters emits a transition for a state the markup
@@ -175,14 +182,30 @@ export class AdwNavigationSplitView extends HTMLElement {
175
182
  // Which children EXIST decides the stack, so the panes are mounted into the state
176
183
  // and not merely into the DOM — a lone child stays visible whatever
177
184
  // `show-content` says, which two CSS classes cannot express.
178
- this._state.setSidebar(sidebarChildren.length > 0 ? { tag: this._tagOf(sidebarChildren) } : null);
179
- this._state.setContent(contentChildren.length > 0 ? { tag: this._tagOf(contentChildren) } : null);
185
+ this._mountPane('sidebar');
186
+ this._mountPane('content');
180
187
  this._state.subscribe(() => {
181
188
  this._reflectShowContent();
182
189
  this._syncClasses();
183
190
  });
184
191
  }
185
192
 
193
+ /**
194
+ * Mount whatever a pane box currently HOLDS into the state — `null` while it is empty.
195
+ *
196
+ * Re-mounting an unchanged pane re-emits the plan, because the state compares the page
197
+ * ref by identity. Both subscribers are idempotent reflections (an attribute and a
198
+ * class list), so a second child in the same pane costs a repaint decision and never a
199
+ * navigation event.
200
+ */
201
+ private _mountPane(pane: 'sidebar' | 'content'): void {
202
+ const box = pane === 'sidebar' ? this._sidebarEl : this._contentEl;
203
+ const children = Array.from(box.children);
204
+ const page = children.length > 0 ? { tag: this._tagOf(children) } : null;
205
+ if (pane === 'sidebar') this._state.setSidebar(page);
206
+ else this._state.setContent(page);
207
+ }
208
+
186
209
  /** `Adw.NavigationPage:tag` off the slotted pane, or `null` when untagged. */
187
210
  private _tagOf(children: readonly Element[]): string | null {
188
211
  for (const child of children) {
@@ -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
- this.append(this._sidebarEl, this._contentEl, this._backdropEl);
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
- this.replaceChildren(this._scrimEl, this._dialogEl);
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
- * 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