@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.
@@ -0,0 +1,212 @@
1
+ // Light-DOM slot routing that stays LIVE — "where a `[slot=]` child lands", kept true
2
+ // after connect instead of only at parse time.
3
+ //
4
+ // THE INCIDENT
5
+ //
6
+ // 42 of the element files under `src/elements/` install their own subtree with
7
+ // `this.replaceChildren(…)`, and the ones with slots snapshot the author's children in
8
+ // the line before it — ONCE, inside the `_initialized` guard. Every one of them is
9
+ // therefore a parse-time-only slot. `row.append(icon)` with `slot="prefix"` after connect
10
+ // leaves `icon` as a sibling of the three internal sections rather than inside
11
+ // `prefixSection`, where the byte-identical DECLARED child lands; nothing throws and
12
+ // nothing logs. `adw-header-bar.spec.ts` had the workaround written down as a test
13
+ // convention — "Appended LAST: the sections are built in `connectedCallback`, so the
14
+ // slotted children have to be in place before the bar enters the document" — which is
15
+ // this bug recorded as a house rule.
16
+ //
17
+ // It is wrong for hand-written HTML that appends after load, and it is what ADR 0027 § 9
18
+ // names as the obstacle to one widget vocabulary across GTK, Blueprint, JSX, Vue and this
19
+ // pillar: a renderer mutates its tree after mount by definition, so the same authored
20
+ // markup cannot drive both surfaces while a slot is a one-shot read.
21
+ //
22
+ // WHY A MutationObserver, and not either alternative
23
+ //
24
+ // - **native `<slot>`** needs a shadow root, and ADR 0010 keeps this package light-DOM
25
+ // ON PURPOSE: a shadow boundary is symmetric, so it would block the host page's CSS
26
+ // from overriding IN, which is the overridability the `--adw-*` token contract sells.
27
+ // That ADR lists native `<slot>` as part of the deferred shadow path and gates it
28
+ // behind an ADR of its own — so it is not an option available to this fix. No element
29
+ // in the package calls `attachShadow` today.
30
+ // - **re-homing from the CHILD's `connectedCallback`** only reaches children that are
31
+ // themselves custom elements: a plain `<button slot="suffix">` and a bare text node
32
+ // have no callback, and both are slotted here today. It also inverts ownership —
33
+ // every child would have to know its parent's internal structure — so it cannot live
34
+ // in ONE place, which is the whole point of this module. And `connectedCallback`
35
+ // fires on every re-parent of the subtree, so it would re-home on a move that changed
36
+ // nothing.
37
+ //
38
+ // The observer is also the technique this package already reached for twice for the same
39
+ // "evaluated once at connect time" class: `<adw-clamp>` for its child width, and
40
+ // `src/empty-sections.ts` for the `hidden` derivation.
41
+ //
42
+ // WHAT THIS DELIBERATELY DOES NOT DO: reorder.
43
+ //
44
+ // A late child is APPENDED to its destination, which is where a parse-time child in the
45
+ // same position lands. Nothing finer is expressible: `host.insertBefore(child, ref)` with
46
+ // an already-adopted `ref` throws `NotFoundError` in the DOM before this module is
47
+ // reached, because `ref` is no longer a child of the host. Order fidelity under reordering
48
+ // is a property of native `<slot>`, i.e. of the shadow path ADR 0010 § 3 defers — until
49
+ // then a renderer that reorders must reorder inside the destination it was given, and
50
+ // that divergence is written down here rather than absorbed.
51
+ //
52
+ // NOTHING TO TEAR DOWN, deliberately. The observed node is the HOST, so the observer is
53
+ // unreachable the moment the element is and is collected with it — the same reasoning
54
+ // `src/empty-sections.ts` writes above its own. Only a binding that reaches OUTSIDE
55
+ // (document, window, a media query) has to be released on disconnect and re-established
56
+ // on every connect, which is what `scripts/check-adwaita-connect-rebind.mjs` polices.
57
+ // Staying bound while detached is also the better behaviour: a child appended to a parked
58
+ // widget is already in the right place when it comes back.
59
+
60
+ export interface AdwSlotClaim {
61
+ /** The `slot="…"` value claimed. Omit for the DEFAULT slot — everything else. */
62
+ readonly name?: string;
63
+ /**
64
+ * Claim by NODE as well as by slot name, for the typed-child spelling
65
+ * (`<adw-alert-response>` IS the slot) and for a default that takes only some node
66
+ * kinds (`<adw-entry-row>` adopts elements and drops stray whitespace, as GtkBuildable
67
+ * does). Tried after an explicit `slot=` name and before the default, so a claim can
68
+ * never swallow a child that named a different slot.
69
+ */
70
+ readonly claims?: (node: Node) => boolean;
71
+ }
72
+
73
+ /** A destination that RE-HOMES what it claims — the ordinary slot. */
74
+ export interface AdwSlotInto extends AdwSlotClaim {
75
+ readonly into: HTMLElement;
76
+ }
77
+
78
+ /**
79
+ * A slot whose child is consumed by a CALL rather than by a move: either data —
80
+ * `<adw-alert-response id="ok">` is read for its id, label and appearance and then gone,
81
+ * exactly as GtkBuilder leaves nothing of a `<responses>` entry in the widget tree — or a
82
+ * widget whose insertion rule is the element's own (`addPrefix` PREPENDS, mirroring
83
+ * `gtk_box_prepend`). A node the call did not move out of the host is dropped, so a
84
+ * data-only child cannot linger as a stray sibling of the internal structure.
85
+ */
86
+ export interface AdwSlotConsume extends AdwSlotClaim {
87
+ readonly consume: (node: Node) => void;
88
+ }
89
+
90
+ export type AdwSlot = AdwSlotInto | AdwSlotConsume;
91
+
92
+ export interface AdwSlottedChildren {
93
+ /** The declared slots, so a driver can probe them without a hand-written ledger. */
94
+ readonly slots: readonly AdwSlot[];
95
+ /**
96
+ * Route the host's CURRENT children into their slots, then make `structure` the
97
+ * host's own children, then stay live.
98
+ *
99
+ * The three steps are one call because their ORDER is the invariant: the author's
100
+ * children have to leave the host before it is emptied, or `replaceChildren` destroys
101
+ * them — the bug `<adw-entry-row>` records above its own `_authored` snapshot. Passing
102
+ * `structure` here also means the set this module must never route is DERIVED from the
103
+ * call that installs it, rather than a second list to keep in sync.
104
+ *
105
+ * Call once per built subtree; a second call replaces the observer rather than
106
+ * stacking one.
107
+ */
108
+ install(...structure: readonly Node[]): void;
109
+ /** The slot `node` belongs to, or `null` when none claims it. */
110
+ slotFor(node: Node): AdwSlot | null;
111
+ }
112
+
113
+ /** Where a bound host's slots can be read back from — see {@link slottedChildrenOf}. */
114
+ const BOUND = new WeakMap<Element, AdwSlottedChildren>();
115
+
116
+ /**
117
+ * The slot binding `host` installed, if it has one.
118
+ *
119
+ * This is how `slotted-children.spec.ts` drives EVERY registered element rather than a
120
+ * list of the ones someone remembered: an element that binds a slot enrols itself in the
121
+ * placement driver, and one that stops binding drops out of it visibly.
122
+ */
123
+ export function slottedChildrenOf(host: Element): AdwSlottedChildren | undefined {
124
+ return BOUND.get(host);
125
+ }
126
+
127
+ /**
128
+ * Bind `host`'s light-DOM children to `slots`.
129
+ *
130
+ * `onAdopt` fires for a child adopted AFTER {@link AdwSlottedChildren.install} and not
131
+ * during it: the install pass IS the element's own build, which seeds its state on the
132
+ * next line with everything that landed, so firing there would make every element's build
133
+ * order depend on how many children the author wrote.
134
+ */
135
+ export function bindSlottedChildren(
136
+ host: HTMLElement,
137
+ slots: readonly AdwSlot[],
138
+ onAdopt?: (node: Node, slot: AdwSlot) => void,
139
+ ): AdwSlottedChildren {
140
+ const named = new Map<string, AdwSlot>();
141
+ const typed: Array<[(node: Node) => boolean, AdwSlot]> = [];
142
+ let fallback: AdwSlot | null = null;
143
+ for (const slot of slots) {
144
+ const claims = slot.claims;
145
+ if (slot.name !== undefined) named.set(slot.name, slot);
146
+ if (claims !== undefined) typed.push([claims, slot]);
147
+ if (slot.name === undefined && claims === undefined) fallback = slot;
148
+ }
149
+
150
+ /** The host's OWN children, installed by this module — never routed back into a slot. */
151
+ let structure: ReadonlySet<Node> = new Set();
152
+ let observer: MutationObserver | null = null;
153
+
154
+ const slotFor = (node: Node): AdwSlot | null => {
155
+ if (structure.has(node)) return null;
156
+ // An explicit `slot=` NAME is answered first and answered alone: `slot=""` is the
157
+ // default slot in the native assignment algorithm, and an unmatched name is
158
+ // assigned nowhere at all. Both are copied deliberately — a `slot="sufix"` typo
159
+ // that silently became default content would render in the wrong place and look
160
+ // intentional, where a child that visibly stays put does not.
161
+ const name = node instanceof Element ? (node.getAttribute('slot') ?? '') : '';
162
+ if (name !== '') return named.get(name) ?? null;
163
+ // Then the typed claims (`<adw-alert-response>` IS the slot), then the default.
164
+ for (const [claims, slot] of typed) if (claims(node)) return slot;
165
+ return fallback;
166
+ };
167
+
168
+ const route = (node: Node): AdwSlot | null => {
169
+ const slot = slotFor(node);
170
+ if (slot === null) return null;
171
+ if ('into' in slot) {
172
+ slot.into.appendChild(node);
173
+ return slot;
174
+ }
175
+ slot.consume(node);
176
+ // Data, not content: the call read what it needed and left the node where it was,
177
+ // so it has to go. A `consume` that MOVED the node (`addPrefix`) has already
178
+ // changed the parent and this is a no-op.
179
+ if (node.parentNode === host) host.removeChild(node);
180
+ return slot;
181
+ };
182
+
183
+ const binding: AdwSlottedChildren = {
184
+ slots,
185
+ slotFor,
186
+ install(...nodes: readonly Node[]) {
187
+ for (const node of Array.from(host.childNodes)) route(node);
188
+ structure = new Set(nodes);
189
+ host.replaceChildren(...nodes);
190
+ // Armed AFTER `replaceChildren`, so its own records are never delivered: an
191
+ // observer armed first would see the whole of `structure` as added nodes and
192
+ // spend a microtask rejecting them.
193
+ observer?.disconnect();
194
+ observer = new MutationObserver((records) => {
195
+ for (const record of records) {
196
+ // Only the HOST's own child list. A destination's records are the
197
+ // CONSEQUENCE of a move, and re-routing them appends each child a
198
+ // second time — the reason `<adw-preferences-group>` filtered on
199
+ // `record.target` back when it was the only element doing this.
200
+ if (record.target !== host) continue;
201
+ for (const node of record.addedNodes) {
202
+ const slot = route(node);
203
+ if (slot !== null) onAdopt?.(node, slot);
204
+ }
205
+ }
206
+ });
207
+ observer.observe(host, { childList: true });
208
+ },
209
+ };
210
+ BOUND.set(host, binding);
211
+ return binding;
212
+ }
@@ -25,6 +25,7 @@ import { AdwActionRowsTest } from './adw-action-rows.spec.js';
25
25
  import { AdwBreakpointsTest } from './breakpoints.spec.js';
26
26
  import { AdwDataGridTest } from './adw-data-grid.spec.js';
27
27
  import { AdwDialogTest } from './adw-dialog.spec.js';
28
+ import { AdwAlertDialogTest } from './adw-alert-dialog.spec.js';
28
29
  import { AdwDropDownTest } from './adw-drop-down.spec.js';
29
30
  import { AdwRowStateTest } from './adw-row-state.spec.js';
30
31
  import { AdwTabViewTest } from './adw-tab-view.spec.js';
@@ -49,6 +50,7 @@ import { AdwAccentTest } from './adw-accent.spec.js';
49
50
  import { AdwShortcutLabelTest } from './adw-shortcut-label.spec.js';
50
51
  import { AdwConnectLifecycleTest } from './connect-lifecycle.spec.js';
51
52
  import { AdwEmptySectionsTest } from './empty-sections.spec.js';
53
+ import { AdwSlottedChildrenTest } from './slotted-children.spec.js';
52
54
  import { AdwKeyboardOperableTest } from './keyboard-operable.spec.js';
53
55
  import { AdwFontsTest } from './adw-fonts.spec.js';
54
56
 
@@ -56,10 +58,12 @@ run({
56
58
  AdwKeyboardOperableTest,
57
59
  AdwConnectLifecycleTest,
58
60
  AdwEmptySectionsTest,
61
+ AdwSlottedChildrenTest,
59
62
  AdwStyleClassesTest,
60
63
  AdwAccentTest,
61
64
  AdwShortcutLabelTest,
62
65
  AdwAboutDialogTest,
66
+ AdwAlertDialogTest,
63
67
  AdwBannerTest,
64
68
  AdwButtonContentTest,
65
69
  AdwIconTest,